diff --git a/.gitignore b/.gitignore index bee8a64..6809b39 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__ +secureput_identity.shelve diff --git a/TODO b/TODO index c0ecb0b..45d2820 100644 --- a/TODO +++ b/TODO @@ -1,4 +1,4 @@ -- iphone support (swift client? or html?) +- [x] iphone - control interface - battery view - camera switcher diff --git a/secureput/__init__.py b/secureput/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secureput/aes.py b/secureput/aes.py new file mode 100644 index 0000000..e5f2d98 --- /dev/null +++ b/secureput/aes.py @@ -0,0 +1,36 @@ +import os +import base64 +from typing import Tuple +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend + +def encrypt(key: bytes, insecure: bytes) -> bytes: + plain_text = insecure.encode() + iv = os.urandom(16) + key_bytes = key.encode() + + cipher = Cipher(algorithms.AES(key_bytes), modes.CBC(iv), backend=default_backend()) + encryptor = cipher.encryptor() + padded_plain_text = PKCS7Padding(plain_text, 16) + cipher_text = iv + encryptor.update(padded_plain_text) + encryptor.finalize() + + return base64.b64encode(cipher_text).decode() + +def decrypt(key: bytes, secure: bytes) -> bytes: + cipher_text = base64.b64decode(secure) + key_bytes = key.encode() + iv = cipher_text[:16] + cipher_text = cipher_text[16:] + + cipher = Cipher(algorithms.AES(key_bytes), modes.CBC(iv), backend=default_backend()) + decryptor = cipher.decryptor() + decrypted_text = decryptor.update(cipher_text) + decryptor.finalize() + + padding_length = decrypted_text[-1] + decrypted_text = decrypted_text[:-padding_length] + return decrypted_text + +def PKCS7Padding(data: bytes, block_size: int) -> bytes: + padding_length = block_size - (len(data) % block_size) + padding = bytes([padding_length]) * padding_length + return data + padding diff --git a/secureput/app.py b/secureput/app.py new file mode 100644 index 0000000..9815815 --- /dev/null +++ b/secureput/app.py @@ -0,0 +1,36 @@ +import shelve +from uuid import uuid4 +from socket import gethostname +import json +import pyqrcode +from secureput.secret import generate_secret_key + +class App: + def __init__(self): + self.config = shelve.open("secureput_identity.shelve") + self.__init_config_default("deviceName", lambda: gethostname()) + self.__init_config_default("deviceUUID", lambda: str(uuid4())) + self.__init_config_default("accountUUID", lambda: None) + + def __init_config_default(self, key, default_value_lambda): + try: + self.config[key] + except KeyError: + self.config[key] = default_value_lambda() + + def paired(self): + return self.config["accountUUID"] != None + + def gen_pair_info(self): + pairing = {} + pairing["secret"] = generate_secret_key() + pairing["uuid"] = self.config["deviceUUID"] + self.config["deviceSecret"] = pairing["secret"] + url = pyqrcode.create(json.dumps(pairing)) + print(url.terminal(quiet_zone=1)) + +if __name__ == "__main__": + app = App() + print(app.config["deviceName"]) + print(app.config["deviceUUID"]) + print(app.gen_pair_info()) \ No newline at end of file diff --git a/secureput/secret.py b/secureput/secret.py new file mode 100644 index 0000000..d594aa4 --- /dev/null +++ b/secureput/secret.py @@ -0,0 +1,12 @@ +import random + +runes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +def rand_seq(n): + b = [] + for i in range(n): + b.append(random.choice(runes)) + return "".join(b) + +def generate_secret_key(): + return rand_seq(32) diff --git a/secureput/secureput_signaling.py b/secureput/secureput_signaling.py new file mode 100644 index 0000000..b577a1f --- /dev/null +++ b/secureput/secureput_signaling.py @@ -0,0 +1,117 @@ +import asyncio +from aiortc.contrib.signaling import RTCSessionDescription, RTCIceCandidate, candidate_from_sdp, candidate_to_sdp +import json + +from secureput.websocket_signaling import WebsocketSignaling +import secureput.aes as aes +from secureput.app import App +import traceback + +class SecureputSignaling(WebsocketSignaling): + def __init__(self, server): + super().__init__(server) + self._app = App() + + if not self._app.paired(): + self._app.gen_pair_info() + + async def connect(self): + await super().connect() + await self.sendIdentity() + + async def close(self): + if self._websocket is not None and self._websocket.open is True: + await self._websocket.close() + + def secret(self): + return self._app.config["deviceSecret"] + + def encrypt(self, data): + return aes.encrypt(self.secret(), data) + + def decrypt(self, data): + return aes.decrypt(self.secret(), data) + + def __object_from_string(self, message_str): + message = json.loads(message_str) + + if message["type"] == "wrapped": + message = json.loads(self.decrypt(message["payload"]["data"])) + + return message + + def __object_to_string(self, obj): + if isinstance(obj, RTCSessionDescription): + message = self.forwardWrap({ + "type": "SessionDescription", + "payload": {"sdp": obj.sdp, "type": obj.type} + }) + elif isinstance(obj, RTCIceCandidate): + message = self.forwardWrap({ + "type": "IceCandidate", + "payload": { + "sdp": "candidate:" + candidate_to_sdp(obj), + "sdpMid": obj.sdpMid, + "sdpMLineIndex": obj.sdpMLineIndex, + } + }) + else: + message = obj + + return json.dumps(message, sort_keys=True) + + async def send(self, descr): + data = self.__object_to_string(descr) + print("Websocket send: %s" % data) + await self._websocket.send(data + '\n') + + async def receive(self): + try: + data = await self._websocket.recv() + except asyncio.IncompleteReadError: + return + ret = self.__object_from_string(data) + if ret == None: + print("remote host says good bye!") + elif isinstance(ret, dict): + if ret['type'] == 'claim': + await self.claim(ret['payload']['account']) + else: + if ret["type"] == "SessionDescription": + sdp = ret["payload"]["sdp"] + type = ret["payload"]["type"] + return RTCSessionDescription(sdp=sdp, type=type) + elif ret["type"] == "IceCandidate": + candidate = candidate_from_sdp(ret["payload"]["sdp"].split(":", 1)[1]) + candidate.sdpMid = ret["payload"]["sdpMid"] + candidate.sdpMLineIndex = ret["payload"]["sdpMLineIndex"] + return candidate + return ret + + async def sendIdentity(self): + await self._websocket.send(json.dumps({ + "type": "identify-target", + "payload": { + "name": self._app.config["deviceName"], + "device": self._app.config["deviceUUID"], + "account": self._app.config["accountUUID"] + } + })) + + async def claim(self, account): + self._app.config["accountUUID"] = account + print("claimed to account %s" % account) + await self.sendIdentity() + + def forwardWrap(self, json_data: dict) -> str: + msg = { + 'to': self._app.config["accountUUID"], + 'type': 'forward-wrapped', + } + # Encrypt the plaintext + ciphertext = self.encrypt(json.dumps(json_data)) + + # Install the ciphertext into the payload + msg['body'] = ciphertext + + return msg diff --git a/secureput/websocket_signaling.py b/secureput/websocket_signaling.py new file mode 100644 index 0000000..fda60e6 --- /dev/null +++ b/secureput/websocket_signaling.py @@ -0,0 +1,32 @@ +import asyncio +import websockets + +from aiortc.contrib.signaling import object_from_string, object_to_string + +class WebsocketSignaling: + def __init__(self, server): + self._server = server + self._websocket = None + + async def connect(self): + self._websocket = await websockets.connect(self._server) + + async def close(self): + if self._websocket is not None and self._websocket.open is True: + await self.send(None) + await self._websocket.close() + + async def receive(self): + try: + data = await self._websocket.recv() + except asyncio.IncompleteReadError: + return + ret = object_from_string(data) + if ret == None: + print("remote host says good bye!") + + return ret + + async def send(self, descr): + data = object_to_string(descr) + await self._websocket.send(data + '\n') \ No newline at end of file diff --git a/server.py b/server.py index ae14811..873780c 100755 --- a/server.py +++ b/server.py @@ -6,104 +6,94 @@ import logging import os import ssl from typing import OrderedDict -from aiohttp import web -from aiortc import RTCPeerConnection, RTCSessionDescription, RTCRtpCodecCapability +from aiortc import RTCPeerConnection, RTCSessionDescription, RTCIceCandidate, RTCRtpCodecCapability, RTCConfiguration, RTCIceServer from compressed_vipc_track import VisionIpcTrack from desktop_stream_track import DesktopStreamTrack +from aiortc.contrib.signaling import BYE +from secureput.secureput_signaling import SecureputSignaling -ROOT = os.path.dirname(__file__) +# optional, for better performance +# try: +# import uvloop +# except ImportError: +# uvloop = None cams = ["roadEncodeData","wideRoadEncodeData","driverEncodeData"] +cam = 1 -async def index(request): - content = open(os.path.join(ROOT, "index.html"), "r").read() - return web.Response(content_type="text/html", text=content) +async def signal(pc, signaling): + await signaling.connect() -async def javascript(request): - content = open(os.path.join(ROOT, "client.js"), "r").read() - return web.Response(content_type="application/javascript", text=content) + while True: + obj = await signaling.receive() + # The peer trickles, but aiortc doesn't https://github.com/aiortc/aiortc/issues/227 + # > aioice, the library which aiortc uses for ICE does not trickle ICE candidates: + # > you get all the candidates in one go. As such once you have called setLocalDescription() + # > for your offer or answer, all your ICE candidates are listed in pc.localDescription. + if isinstance(obj, RTCIceCandidate): + pc.addIceCandidate(obj) -async def offer(request): - params = await request.json() - offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"]) + if isinstance(obj, RTCSessionDescription): + if pc != None and pc.iceConnectionState != "failed": + await pc.close() + pc = RTCPeerConnection(configuration=RTCConfiguration([RTCIceServer("stun:stun.secureput.com:3478")])) - pc = RTCPeerConnection() - pcs.add(pc) + @pc.on("connectionstatechange") + async def on_connectionstatechange(): + print("Connection state is %s" % pc.iceConnectionState) + if pc.iceConnectionState == "failed": + await pc.close() - @pc.on("connectionstatechange") - async def on_connectionstatechange(): - print("Connection state is %s" % pc.connectionState) - if pc.connectionState == "failed": - await pc.close() - pcs.discard(pc) + @pc.on('datachannel') + def on_datachannel(channel): + print("data channel!") + @channel.on('message') + async def on_message(message): + print("message!!") - # TODO: stream the microphone - audio = None - - # video = VisionIpcTrack(cams[int(args.cam)], args.addr) - video = DesktopStreamTrack() - - video_sender = pc.addTrack(video) - - await pc.setRemoteDescription(offer) - - answer = await pc.createAnswer() - await pc.setLocalDescription(answer) - - return web.Response( - content_type="application/json", - text=json.dumps( - {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type} - ), - ) - - -pcs = set() - - -async def on_shutdown(app): - # close peer connections - coros = [pc.close() for pc in pcs] - await asyncio.gather(*coros) - pcs.clear() + await pc.setRemoteDescription(obj) + + if obj.type == 'offer': + # TODO: stream the microphone + audio = None + video = VisionIpcTrack(cams[int(cam)], "tici") + # video = DesktopStreamTrack() + pc.addTrack(video) + answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + await signaling.send(pc.localDescription) if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Decode video streams and broadcast via WebRTC") + parser = argparse.ArgumentParser(description="Comma Body WebRTC Service") parser.add_argument("--addr", default='tici', help="Address of comma three") # Not implemented (yet?). Geo already made the PoC for this, it should be possible. # parser.add_argument("--nvidia", action="store_true", help="Use nvidia instead of ffmpeg") - parser.add_argument("--cam", default="0", help="Camera to stream") - - parser.add_argument("--cert-file", help="SSL certificate file (for HTTPS)") - parser.add_argument("--key-file", help="SSL key file (for HTTPS)") - parser.add_argument( - "--host", default="0.0.0.0", help="Host for HTTP server (default: 0.0.0.0)" - ) - parser.add_argument( - "--port", type=int, default=8080, help="Port for HTTP server (default: 8080)" - ) + parser.add_argument("--signaling-server", default="wss://signal.secureput.com", help="Signaling server to use") + parser.add_argument("--stun-server", default="stun:stun.secureput.com:3478", help="STUN server to use") parser.add_argument("--verbose", "-v", action="count") - args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - if args.cert_file: - ssl_context = ssl.SSLContext() - ssl_context.load_cert_chain(args.cert_file, args.key_file) - else: - ssl_context = None + # if uvloop is not None: + # asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) - app = web.Application() - app.on_shutdown.append(on_shutdown) - app.router.add_get("/", index) - app.router.add_get("/client.js", javascript) - app.router.add_post("/offer", offer) - web.run_app(app, host=args.host, port=args.port, ssl_context=ssl_context) \ No newline at end of file + pc = RTCPeerConnection(configuration=RTCConfiguration([RTCIceServer(args.stun_server)])) + signaling = SecureputSignaling(args.signaling_server) + + coro = signal(pc, signaling) + + # run event loop + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(coro) + except KeyboardInterrupt: + pass + finally: + loop.run_until_complete(pc.close()) + loop.run_until_complete(signaling.close()) \ No newline at end of file diff --git a/signaling.py b/signaling.py deleted file mode 100644 index 8394373..0000000 --- a/signaling.py +++ /dev/null @@ -1,217 +0,0 @@ -import asyncio -import json -import logging -import os -import sys - -from aiortc import RTCIceCandidate, RTCSessionDescription -from aiortc.sdp import candidate_from_sdp, candidate_to_sdp - -logger = logging.getLogger(__name__) -BYE = object() - - -def object_from_string(message_str): - message = json.loads(message_str) - if message["type"] in ["answer", "offer"]: - return RTCSessionDescription(**message) - elif message["type"] == "candidate" and message["candidate"]: - candidate = candidate_from_sdp(message["candidate"].split(":", 1)[1]) - candidate.sdpMid = message["id"] - candidate.sdpMLineIndex = message["label"] - return candidate - elif message["type"] == "bye": - return BYE - - -def object_to_string(obj): - if isinstance(obj, RTCSessionDescription): - message = {"sdp": obj.sdp, "type": obj.type} - elif isinstance(obj, RTCIceCandidate): - message = { - "candidate": "candidate:" + candidate_to_sdp(obj), - "id": obj.sdpMid, - "label": obj.sdpMLineIndex, - "type": "candidate", - } - else: - assert obj is BYE - message = {"type": "bye"} - return json.dumps(message, sort_keys=True) - - -class CopyAndPasteSignaling: - def __init__(self): - self._read_pipe = sys.stdin - self._read_transport = None - self._reader = None - self._write_pipe = sys.stdout - - async def connect(self): - loop = asyncio.get_event_loop() - self._reader = asyncio.StreamReader(loop=loop) - self._read_transport, _ = await loop.connect_read_pipe( - lambda: asyncio.StreamReaderProtocol(self._reader), self._read_pipe - ) - - async def close(self): - if self._reader is not None: - await self.send(BYE) - self._read_transport.close() - self._reader = None - - async def receive(self): - print("-- Please enter a message from remote party --") - data = await self._reader.readline() - print() - return object_from_string(data.decode(self._read_pipe.encoding)) - - async def send(self, descr): - print("-- Please send this message to the remote party --") - self._write_pipe.write(object_to_string(descr) + "\n") - self._write_pipe.flush() - print() - - -class TcpSocketSignaling: - def __init__(self, host, port): - self._host = host - self._port = port - self._server = None - self._reader = None - self._writer = None - - async def connect(self): - pass - - async def _connect(self, server): - if self._writer is not None: - return - - if server: - connected = asyncio.Event() - - def client_connected(reader, writer): - self._reader = reader - self._writer = writer - connected.set() - - self._server = await asyncio.start_server( - client_connected, host=self._host, port=self._port - ) - await connected.wait() - else: - self._reader, self._writer = await asyncio.open_connection( - host=self._host, port=self._port - ) - - async def close(self): - if self._writer is not None: - await self.send(BYE) - self._writer.close() - self._reader = None - self._writer = None - if self._server is not None: - self._server.close() - self._server = None - - async def receive(self): - await self._connect(False) - try: - data = await self._reader.readuntil() - except asyncio.IncompleteReadError: - return - return object_from_string(data.decode("utf8")) - - async def send(self, descr): - await self._connect(True) - data = object_to_string(descr).encode("utf8") - self._writer.write(data + b"\n") - - -class UnixSocketSignaling: - def __init__(self, path): - self._path = path - self._server = None - self._reader = None - self._writer = None - - async def connect(self): - pass - - async def _connect(self, server): - if self._writer is not None: - return - - if server: - connected = asyncio.Event() - - def client_connected(reader, writer): - self._reader = reader - self._writer = writer - connected.set() - - self._server = await asyncio.start_unix_server( - client_connected, path=self._path - ) - await connected.wait() - else: - self._reader, self._writer = await asyncio.open_unix_connection(self._path) - - async def close(self): - if self._writer is not None: - await self.send(BYE) - self._writer.close() - self._reader = None - self._writer = None - if self._server is not None: - self._server.close() - self._server = None - os.unlink(self._path) - - async def receive(self): - await self._connect(False) - try: - data = await self._reader.readuntil() - except asyncio.IncompleteReadError: - return - return object_from_string(data.decode("utf8")) - - async def send(self, descr): - await self._connect(True) - data = object_to_string(descr).encode("utf8") - self._writer.write(data + b"\n") - - -def add_signaling_arguments(parser): - """ - Add signaling method arguments to an argparse.ArgumentParser. - """ - parser.add_argument( - "--signaling", - "-s", - choices=["copy-and-paste", "tcp-socket", "unix-socket"], - ) - parser.add_argument( - "--signaling-host", default="127.0.0.1", help="Signaling host (tcp-socket only)" - ) - parser.add_argument( - "--signaling-port", default=1234, help="Signaling port (tcp-socket only)" - ) - parser.add_argument( - "--signaling-path", - default="aiortc.socket", - help="Signaling socket path (unix-socket only)", - ) - - -def create_signaling(args): - """ - Create a signaling method based on command-line arguments. - """ - if args.signaling == "tcp-socket": - return TcpSocketSignaling(args.signaling_host, args.signaling_port) - elif args.signaling == "unix-socket": - return UnixSocketSignaling(args.signaling_path) - else: - return CopyAndPasteSignaling() \ No newline at end of file