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.
 
 
 
 
 
 

453 lines
11 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. MAC_LEN = 8
  42. def __init__(self, syncdatagram, shared=None):
  43. self.sd = syncdatagram
  44. self.st = Strobe(domain, F=KeccakF(800))
  45. if shared is not None:
  46. self.st.key(shared)
  47. async def start(self):
  48. resp = await self.sendrecvvalid(os.urandom(16) + b'reqreset')
  49. self.st.ratchet()
  50. pkt = await self.sendrecvvalid(b'confirm')
  51. if pkt != b'confirmed':
  52. raise RuntimeError
  53. async def sendrecvvalid(self, msg):
  54. msg = self.st.send_enc(msg) + self.st.send_mac(self.MAC_LEN)
  55. origstate = self.st.copy()
  56. while True:
  57. resp = await self.sd.sendtillrecv(msg, 1)
  58. #_debprint('got:', resp)
  59. try:
  60. decmsg = self.st.recv_enc(resp[:-self.MAC_LEN])
  61. self.st.recv_mac(resp[-self.MAC_LEN:])
  62. break
  63. except AuthenticationFailed:
  64. # didn't get a valid packet, restore
  65. # state and retry
  66. #_debprint('failed')
  67. self.st.set_state_from(origstate)
  68. #_debprint('got rep:', repr(resp), repr(decmsg))
  69. return decmsg
  70. @staticmethod
  71. def _encodeargs(*args):
  72. r = []
  73. for i in args:
  74. r.append(i.to_bytes(4, byteorder='little'))
  75. return b''.join(r)
  76. async def _sendcmd(self, cmd, *args):
  77. cmdbyte = cmd.to_bytes(1, byteorder='little')
  78. resp = await self.sendrecvvalid(cmdbyte + self._encodeargs(*args))
  79. if resp[0:1] != cmdbyte:
  80. raise RuntimeError(
  81. 'response does not match, got: %s, expected: %s' %
  82. (repr(resp[0:1]), repr(cmdbyte)))
  83. async def waitfor(self, length):
  84. return await self._sendcmd(CMD_WAITFOR, length)
  85. async def runfor(self, chan, length):
  86. return await self._sendcmd(CMD_RUNFOR, chan, length)
  87. async def terminate(self):
  88. return await self._sendcmd(CMD_TERMINATE)
  89. class SyncDatagram(object):
  90. '''Base interface for a more simple synchronous interface.'''
  91. def __init__(self): #pragma: no cover
  92. pass
  93. async def recv(self, timeout=None): #pragma: no cover
  94. '''Receive a datagram. If timeout is not None, wait that many
  95. seconds, and if nothing is received in that time, raise an
  96. TimeoutError exception.'''
  97. raise NotImplementedError
  98. async def send(self, data): #pragma: no cover
  99. '''Send a datagram.'''
  100. raise NotImplementedError
  101. async def sendtillrecv(self, data, freq):
  102. '''Send the datagram in data, every freq seconds until a datagram
  103. is received. If timeout seconds happen w/o receiving a datagram,
  104. then raise an TimeoutError exception.'''
  105. while True:
  106. #_debprint('sending:', repr(data))
  107. await self.send(data)
  108. try:
  109. return await self.recv(freq)
  110. except TimeoutError:
  111. pass
  112. class MockSyncDatagram(SyncDatagram):
  113. '''A testing version of SyncDatagram. Define a method runner which
  114. implements part of the sequence. In the function, await on either
  115. self.get, to wait for the other side to send something, or await
  116. self.put w/ data to send.'''
  117. def __init__(self):
  118. self.sendq = asyncio.Queue()
  119. self.recvq = asyncio.Queue()
  120. self.task = None
  121. self.task = asyncio.create_task(self.runner())
  122. self.get = self.sendq.get
  123. self.put = self.recvq.put
  124. async def drain(self):
  125. '''Wait for the runner thread to finish up.'''
  126. return await self.task
  127. async def runner(self): #pragma: no cover
  128. raise NotImplementedError
  129. async def recv(self, timeout=None):
  130. return await self.recvq.get()
  131. async def send(self, data):
  132. return await self.sendq.put(data)
  133. def __del__(self): #pragma: no cover
  134. if self.task is not None and not self.task.done():
  135. self.task.cancel()
  136. class TestSyncData(unittest.IsolatedAsyncioTestCase):
  137. async def test_syncsendtillrecv(self):
  138. class MySync(SyncDatagram):
  139. def __init__(self):
  140. self.sendq = []
  141. self.resp = [ TimeoutError(), b'a' ]
  142. async def recv(self, timeout=None):
  143. assert timeout == 1
  144. r = self.resp.pop(0)
  145. if isinstance(r, Exception):
  146. raise r
  147. return r
  148. async def send(self, data):
  149. self.sendq.append(data)
  150. ms = MySync()
  151. r = await ms.sendtillrecv(b'foo', 1)
  152. self.assertEqual(r, b'a')
  153. self.assertEqual(ms.sendq, [ b'foo', b'foo' ])
  154. def timeout(timeout):
  155. def timeout_wrapper(fun):
  156. @functools.wraps(fun)
  157. async def wrapper(*args, **kwargs):
  158. return await asyncio.wait_for(fun(*args, **kwargs),
  159. timeout)
  160. return wrapper
  161. return timeout_wrapper
  162. def _debprint(*args): # pragma: no cover
  163. import traceback, sys, os.path
  164. st = traceback.extract_stack(limit=2)[0]
  165. sep = ''
  166. if args:
  167. sep = ':'
  168. print('%s:%d%s' % (os.path.basename(st.filename), st.lineno, sep),
  169. *args)
  170. sys.stdout.flush()
  171. class TestLORANode(unittest.IsolatedAsyncioTestCase):
  172. @timeout(2)
  173. async def test_lora(self):
  174. _self = self
  175. shared_key = os.urandom(32)
  176. class TestSD(MockSyncDatagram):
  177. async def sendgettest(self, msg):
  178. '''Send the message, but make sure that if a
  179. bad message is sent afterward, that it replies
  180. w/ the same previous message.
  181. '''
  182. await self.put(msg)
  183. resp = await self.get()
  184. await self.put(b'bogusmsg' * 5)
  185. resp2 = await self.get()
  186. _self.assertEqual(resp, resp2)
  187. return resp
  188. async def runner(self):
  189. l = Strobe(domain, F=KeccakF(800))
  190. l.key(shared_key)
  191. # start handshake
  192. r = await self.get()
  193. pkt = l.recv_enc(r[:-8])
  194. l.recv_mac(r[-8:])
  195. assert pkt.endswith(b'reqreset')
  196. # make sure junk gets ignored
  197. await self.put(b'sdlfkj')
  198. # and that the packet remains the same
  199. _self.assertEqual(r, await self.get())
  200. # and a couple more times
  201. await self.put(b'0' * 24)
  202. _self.assertEqual(r, await self.get())
  203. await self.put(b'0' * 32)
  204. _self.assertEqual(r, await self.get())
  205. # send the response
  206. await self.put(l.send_enc(os.urandom(16)) +
  207. l.send_mac(8))
  208. # require no more back tracking at this point
  209. l.ratchet()
  210. # get the confirmation message
  211. r = await self.get()
  212. # test the resend capabilities
  213. await self.put(b'0' * 24)
  214. _self.assertEqual(r, await self.get())
  215. # decode confirmation message
  216. c = l.recv_enc(r[:-8])
  217. l.recv_mac(r[-8:])
  218. # assert that we got it
  219. _self.assertEqual(c, b'confirm')
  220. # send confirmed reply
  221. r = await self.sendgettest(l.send_enc(
  222. b'confirmed') + l.send_mac(8))
  223. # test and decode remaining command messages
  224. cmd = l.recv_enc(r[:-8])
  225. l.recv_mac(r[-8:])
  226. assert cmd[0] == CMD_WAITFOR
  227. assert int.from_bytes(cmd[1:],
  228. byteorder='little') == 30
  229. r = await self.sendgettest(l.send_enc(
  230. cmd[0:1]) + l.send_mac(8))
  231. cmd = l.recv_enc(r[:-8])
  232. l.recv_mac(r[-8:])
  233. assert cmd[0] == CMD_RUNFOR
  234. assert int.from_bytes(cmd[1:5],
  235. byteorder='little') == 1
  236. assert int.from_bytes(cmd[5:],
  237. byteorder='little') == 50
  238. r = await self.sendgettest(l.send_enc(
  239. cmd[0:1]) + l.send_mac(8))
  240. cmd = l.recv_enc(r[:-8])
  241. l.recv_mac(r[-8:])
  242. assert cmd[0] == CMD_TERMINATE
  243. await self.put(l.send_enc(cmd[0:1]) +
  244. l.send_mac(8))
  245. tsd = TestSD()
  246. l = LORANode(tsd, shared=shared_key)
  247. await l.start()
  248. await l.waitfor(30)
  249. await l.runfor(1, 50)
  250. await l.terminate()
  251. await tsd.drain()
  252. # Make sure all messages have been processed
  253. self.assertTrue(tsd.sendq.empty())
  254. self.assertTrue(tsd.recvq.empty())
  255. #_debprint('done')
  256. @timeout(2)
  257. async def test_ccode(self):
  258. _self = self
  259. from ctypes import pointer, sizeof, c_uint8
  260. # seed the RNG
  261. prngseed = b'abc123'
  262. lora_comms.strobe_seed_prng((c_uint8 *
  263. len(prngseed))(*prngseed), len(prngseed))
  264. # Create the state for testing
  265. commstate = lora_comms.CommsState()
  266. # These are the expected messages and their arguments
  267. exptmsgs = [
  268. (CMD_WAITFOR, [ 30 ]),
  269. (CMD_RUNFOR, [ 1, 50 ]),
  270. (CMD_TERMINATE, [ ]),
  271. ]
  272. def procmsg(msg, outbuf):
  273. msgbuf = msg._from()
  274. #print('procmsg:', repr(msg), repr(msgbuf), repr(outbuf))
  275. cmd = msgbuf[0]
  276. args = [ int.from_bytes(msgbuf[x:x + 4],
  277. byteorder='little') for x in range(1, len(msgbuf),
  278. 4) ]
  279. if exptmsgs[0] == (cmd, args):
  280. exptmsgs.pop(0)
  281. outbuf[0].pkt[0] = cmd
  282. outbuf[0].pktlen = 1
  283. else: #pragma: no cover
  284. raise RuntimeError('cmd not found')
  285. # wrap the callback function
  286. cb = lora_comms.process_msgfunc_t(procmsg)
  287. class CCodeSD(MockSyncDatagram):
  288. async def runner(self):
  289. for expectlen in [ 24, 17, 9, 9, 9 ]:
  290. # get message
  291. gb = await self.get()
  292. r = make_pktbuf(gb)
  293. outbytes = bytearray(64)
  294. outbuf = make_pktbuf(outbytes)
  295. # process the test message
  296. lora_comms.comms_process(commstate, r,
  297. outbuf)
  298. # make sure the reply matches length
  299. _self.assertEqual(expectlen,
  300. outbuf.pktlen)
  301. # save what was originally replied
  302. origmsg = outbuf._from()
  303. # pretend that the reply didn't make it
  304. r = make_pktbuf(gb)
  305. outbuf = make_pktbuf(outbytes)
  306. lora_comms.comms_process(commstate, r,
  307. outbuf)
  308. # make sure that the reply matches
  309. # the previous
  310. _self.assertEqual(origmsg,
  311. outbuf._from())
  312. # pass the reply back
  313. await self.put(outbytes[:outbuf.pktlen])
  314. # Generate shared key
  315. shared_key = os.urandom(32)
  316. # Initialize everything
  317. lora_comms.comms_init(commstate, cb, make_pktbuf(shared_key))
  318. # Create test fixture
  319. tsd = CCodeSD()
  320. l = LORANode(tsd, shared=shared_key)
  321. # Send various messages
  322. await l.start()
  323. await l.waitfor(30)
  324. await l.runfor(1, 50)
  325. await l.terminate()
  326. await tsd.drain()
  327. # Make sure all messages have been processed
  328. self.assertTrue(tsd.sendq.empty())
  329. self.assertTrue(tsd.recvq.empty())
  330. # Make sure all the expected messages have been
  331. # processed.
  332. self.assertFalse(exptmsgs)
  333. #_debprint('done')