Implement a secure ICS protocol targeting LoRa Node151 microcontroller for controlling irrigation.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

382 lines
9.7 KiB

  1. # Copyright 2021 John-Mark Gurney.
  2. #
  3. # Redistribution and use in source and binary forms, with or without
  4. # modification, are permitted provided that the following conditions
  5. # are met:
  6. # 1. Redistributions of source code must retain the above copyright
  7. # notice, this list of conditions and the following disclaimer.
  8. # 2. Redistributions in binary form must reproduce the above copyright
  9. # notice, this list of conditions and the following disclaimer in the
  10. # documentation and/or other materials provided with the distribution.
  11. #
  12. # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
  13. # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  14. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
  15. # ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
  16. # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  17. # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
  18. # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
  19. # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
  20. # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
  21. # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
  22. # SUCH DAMAGE.
  23. #
  24. import asyncio
  25. import functools
  26. import os
  27. import unittest
  28. from Strobe.Strobe import Strobe, KeccakF
  29. from Strobe.Strobe import AuthenticationFailed
  30. import lora_comms
  31. from lora_comms import make_pktbuf
  32. domain = b'com.funkthat.lora.irrigation.shared.v0.0.1'
  33. # Response to command will be the CMD and any arguments if needed.
  34. # The command is encoded as an unsigned byte
  35. CMD_TERMINATE = 1 # no args: terminate the sesssion, reply confirms
  36. # The follow commands are queue up, but will be acknoledged when queued
  37. CMD_WAITFOR = 2 # arg: (length): waits for length seconds
  38. CMD_RUNFOR = 3 # arg: (chan, length): turns on chan for length seconds
  39. class LORANode(object):
  40. '''Implement a LORANode initiator.'''
  41. def __init__(self, syncdatagram, shared=None):
  42. self.sd = syncdatagram
  43. self.st = Strobe(domain, F=KeccakF(800))
  44. if shared is not None:
  45. self.st.key(shared)
  46. async def start(self):
  47. msg = self.st.send_enc(os.urandom(16) + b'reqreset') + \
  48. self.st.send_mac(8)
  49. resp = await self.sd.sendtillrecv(msg, 1)
  50. self.st.recv_enc(resp[:16])
  51. self.st.recv_mac(resp[16:])
  52. self.st.ratchet()
  53. resp = await self.sd.sendtillrecv(
  54. self.st.send_enc(b'confirm') + self.st.send_mac(8), 1)
  55. pkt = self.st.recv_enc(resp[:9])
  56. self.st.recv_mac(resp[9:])
  57. if pkt != b'confirmed':
  58. raise RuntimeError
  59. @staticmethod
  60. def _encodeargs(*args):
  61. r = []
  62. for i in args:
  63. r.append(i.to_bytes(4, byteorder='little'))
  64. return b''.join(r)
  65. async def _sendcmd(self, cmd, *args):
  66. cmdbyte = cmd.to_bytes(1, byteorder='little')
  67. pkt = await self.sd.sendtillrecv(
  68. self.st.send_enc(cmdbyte +
  69. self._encodeargs(*args)) + self.st.send_mac(8), 1)
  70. resp = self.st.recv_enc(pkt[:-8])
  71. self.st.recv_mac(pkt[-8:])
  72. if resp[0:1] != cmdbyte:
  73. raise RuntimeError('response does not match, got: %s, expected: %s' % (repr(resp[0:1]), repr(cmdbyte)))
  74. async def waitfor(self, length):
  75. return await self._sendcmd(CMD_WAITFOR, length)
  76. async def runfor(self, chan, length):
  77. return await self._sendcmd(CMD_RUNFOR, chan, length)
  78. async def terminate(self):
  79. return await self._sendcmd(CMD_TERMINATE)
  80. class SyncDatagram(object):
  81. '''Base interface for a more simple synchronous interface.'''
  82. def __init__(self): #pragma: no cover
  83. pass
  84. async def recv(self, timeout=None): #pragma: no cover
  85. '''Receive a datagram. If timeout is not None, wait that many
  86. seconds, and if nothing is received in that time, raise an TimeoutError
  87. exception.'''
  88. raise NotImplementedError
  89. async def send(self, data): #pragma: no cover
  90. '''Send a datagram.'''
  91. raise NotImplementedError
  92. async def sendtillrecv(self, data, freq):
  93. '''Send the datagram in data, every freq seconds until a datagram
  94. is received. If timeout seconds happen w/o receiving a datagram,
  95. then raise an TimeoutError exception.'''
  96. while True:
  97. await self.send(data)
  98. try:
  99. return await self.recv(freq)
  100. except TimeoutError:
  101. pass
  102. class MockSyncDatagram(SyncDatagram):
  103. '''A testing version of SyncDatagram. Define a method runner which
  104. implements part of the sequence. In the function, await on either
  105. self.get, to wait for the other side to send something, or await
  106. self.put w/ data to send.'''
  107. def __init__(self):
  108. self.sendq = asyncio.Queue()
  109. self.recvq = asyncio.Queue()
  110. self.task = None
  111. self.task = asyncio.create_task(self.runner())
  112. self.get = self.sendq.get
  113. self.put = self.recvq.put
  114. async def drain(self):
  115. '''Wait for the runner thread to finish up.'''
  116. return await self.task
  117. async def runner(self): #pragma: no cover
  118. raise NotImplementedError
  119. async def recv(self, timeout=None):
  120. return await self.recvq.get()
  121. async def send(self, data):
  122. return await self.sendq.put(data)
  123. def __del__(self): #pragma: no cover
  124. if self.task is not None and not self.task.done():
  125. self.task.cancel()
  126. class TestSyncData(unittest.IsolatedAsyncioTestCase):
  127. async def test_syncsendtillrecv(self):
  128. class MySync(SyncDatagram):
  129. def __init__(self):
  130. self.sendq = []
  131. self.resp = [ TimeoutError(), b'a' ]
  132. async def recv(self, timeout=None):
  133. assert timeout == 1
  134. r = self.resp.pop(0)
  135. if isinstance(r, Exception):
  136. raise r
  137. return r
  138. async def send(self, data):
  139. self.sendq.append(data)
  140. ms = MySync()
  141. r = await ms.sendtillrecv(b'foo', 1)
  142. self.assertEqual(r, b'a')
  143. self.assertEqual(ms.sendq, [ b'foo', b'foo' ])
  144. def timeout(timeout):
  145. def timeout_wrapper(fun):
  146. @functools.wraps(fun)
  147. async def wrapper(*args, **kwargs):
  148. return await asyncio.wait_for(fun(*args, **kwargs),
  149. timeout)
  150. return wrapper
  151. return timeout_wrapper
  152. class TestLORANode(unittest.IsolatedAsyncioTestCase):
  153. @timeout(2)
  154. async def test_lora(self):
  155. shared_key = os.urandom(32)
  156. class TestSD(MockSyncDatagram):
  157. async def runner(self):
  158. l = Strobe(domain, F=KeccakF(800))
  159. l.key(shared_key)
  160. # start handshake
  161. r = await self.get()
  162. pkt = l.recv_enc(r[:-8])
  163. l.recv_mac(r[-8:])
  164. assert pkt.endswith(b'reqreset')
  165. await self.put(l.send_enc(os.urandom(16)) +
  166. l.send_mac(8))
  167. l.ratchet()
  168. r = await self.get()
  169. c = l.recv_enc(r[:-8])
  170. l.recv_mac(r[-8:])
  171. assert c == b'confirm'
  172. await self.put(l.send_enc(b'confirmed') +
  173. l.send_mac(8))
  174. r = await self.get()
  175. cmd = l.recv_enc(r[:-8])
  176. l.recv_mac(r[-8:])
  177. assert cmd[0] == CMD_WAITFOR
  178. assert int.from_bytes(cmd[1:], byteorder='little') == 30
  179. await self.put(l.send_enc(cmd[0:1]) +
  180. l.send_mac(8))
  181. r = await self.get()
  182. cmd = l.recv_enc(r[:-8])
  183. l.recv_mac(r[-8:])
  184. assert cmd[0] == CMD_RUNFOR
  185. assert int.from_bytes(cmd[1:5], byteorder='little') == 1
  186. assert int.from_bytes(cmd[5:], byteorder='little') == 50
  187. await self.put(l.send_enc(cmd[0:1]) +
  188. l.send_mac(8))
  189. r = await self.get()
  190. cmd = l.recv_enc(r[:-8])
  191. l.recv_mac(r[-8:])
  192. assert cmd[0] == CMD_TERMINATE
  193. await self.put(l.send_enc(cmd[0:1]) +
  194. l.send_mac(8))
  195. tsd = TestSD()
  196. l = LORANode(tsd, shared=shared_key)
  197. await l.start()
  198. await l.waitfor(30)
  199. await l.runfor(1, 50)
  200. await l.terminate()
  201. await tsd.drain()
  202. # Make sure all messages have been processed
  203. self.assertTrue(tsd.sendq.empty())
  204. self.assertTrue(tsd.recvq.empty())
  205. @timeout(2)
  206. async def test_ccode(self):
  207. _self = self
  208. from ctypes import pointer, sizeof, c_uint8
  209. # seed the RNG
  210. prngseed = b'abc123'
  211. lora_comms.strobe_seed_prng((c_uint8 *
  212. len(prngseed))(*prngseed), len(prngseed))
  213. # Create the state for testing
  214. commstate = lora_comms.CommsState()
  215. # These are the expected messages and their arguments
  216. exptmsgs = [
  217. (CMD_WAITFOR, [ 30 ]),
  218. (CMD_RUNFOR, [ 1, 50 ]),
  219. (CMD_TERMINATE, [ ]),
  220. ]
  221. def procmsg(msg, outbuf):
  222. msgbuf = msg._from()
  223. #print('procmsg:', repr(msg), repr(msgbuf), repr(outbuf))
  224. cmd = msgbuf[0]
  225. args = [ int.from_bytes(msgbuf[x:x + 4],
  226. byteorder='little') for x in range(1, len(msgbuf),
  227. 4) ]
  228. if exptmsgs[0] == (cmd, args):
  229. exptmsgs.pop(0)
  230. outbuf[0].pkt[0] = cmd
  231. outbuf[0].pktlen = 1
  232. else: #pragma: no cover
  233. raise RuntimeError('cmd not found')
  234. # wrap the callback function
  235. cb = lora_comms.process_msgfunc_t(procmsg)
  236. class CCodeSD(MockSyncDatagram):
  237. async def runner(self):
  238. for expectlen in [ 24, 17, 9, 9, 9 ]:
  239. # get message
  240. gb = await self.get()
  241. r = make_pktbuf(gb)
  242. outbytes = bytearray(64)
  243. outbuf = make_pktbuf(outbytes)
  244. # process the test message
  245. lora_comms.comms_process(commstate, r,
  246. outbuf)
  247. # make sure the reply matches length
  248. _self.assertEqual(expectlen,
  249. outbuf.pktlen)
  250. # save what was originally replied
  251. origmsg = outbuf._from()
  252. # pretend that the reply didn't make it
  253. r = make_pktbuf(gb)
  254. outbuf = make_pktbuf(outbytes)
  255. lora_comms.comms_process(commstate, r,
  256. outbuf)
  257. # make sure that the reply matches previous
  258. _self.assertEqual(origmsg, outbuf._from())
  259. # pass the reply back
  260. await self.put(outbytes[:outbuf.pktlen])
  261. # Generate shared key
  262. shared_key = os.urandom(32)
  263. # Initialize everything
  264. lora_comms.comms_init(commstate, cb, make_pktbuf(shared_key))
  265. # Create test fixture
  266. tsd = CCodeSD()
  267. l = LORANode(tsd, shared=shared_key)
  268. # Send various messages
  269. await l.start()
  270. await l.waitfor(30)
  271. await l.runfor(1, 50)
  272. await l.terminate()
  273. await tsd.drain()
  274. # Make sure all messages have been processed
  275. self.assertTrue(tsd.sendq.empty())
  276. self.assertTrue(tsd.recvq.empty())
  277. # Make sure all the expected messages have been
  278. # processed.
  279. self.assertFalse(exptmsgs)