#!/usr/bin/env python3 """Install the signed FriendRewind Messenger release for the current Linux user.""" import base64 import hashlib import json import os import platform import re import shutil import stat import subprocess import sys import tempfile import urllib.parse import urllib.request from pathlib import Path FEED = 'https://friendrewind.com/desktop/releases/stable.json' PUBLIC_KEY = b'''-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAN3eTz/SCEmd6SK/AP0M7mMEb1gsAxYTIIMHd7yTGPAA=\n-----END PUBLIC KEY-----\n''' MAX_FEED, MAX_PACKAGE = 128 * 1024, 500 * 1024 * 1024 class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): raise ValueError('Unexpected redirect') def fetch(url): req = urllib.request.Request(url, headers={'User-Agent': 'FriendRewind-Messenger-Installer/1', 'Cache-Control': 'no-cache'}) return urllib.request.build_opener(NoRedirect).open(req, timeout=30) def verify_feed(raw, key=PUBLIC_KEY): if len(raw) > MAX_FEED: raise ValueError('Release information is too large') outer = json.loads(raw) payload = base64.b64decode(outer['payload'], validate=True) signature = base64.b64decode(outer['signature'], validate=True) if len(signature) != 64: raise ValueError('Invalid release signature') with tempfile.TemporaryDirectory(prefix='fr-verify-') as temp: tmp = Path(temp) (tmp/'key.pem').write_bytes(key) (tmp/'payload').write_bytes(payload) (tmp/'signature').write_bytes(signature) result = subprocess.run(['openssl','pkeyutl','-verify','-pubin','-inkey',str(tmp/'key.pem'),'-rawin','-in',str(tmp/'payload'),'-sigfile',str(tmp/'signature')], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if result.returncode: raise ValueError('Release signature could not be verified') obj = json.loads(payload) item = obj['releases']['linux-x64'] if obj['schema'] != 1 or not re.fullmatch(r'\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', item['version']) or len(item['version']) > 64: raise ValueError('Invalid release version') if not isinstance(item['versionCode'], int) or isinstance(item['versionCode'], bool) or item['versionCode'] < 1: raise ValueError('Invalid release code') if not isinstance(item['size'], int) or isinstance(item['size'], bool) or not 0 < item['size'] <= MAX_PACKAGE: raise ValueError('Invalid release size') if not re.fullmatch(r'[a-f0-9]{64}', item['sha256']): raise ValueError('Invalid checksum') u = urllib.parse.urlsplit(item['url']) if u.scheme != 'https' or u.netloc != 'friendrewind.com' or u.query or u.fragment or not re.fullmatch(r'/desktop/releases/[A-Za-z0-9._-]+\.AppImage',u.path): raise ValueError('Invalid download address') return item def safe_file(path): if path.is_symlink() or (path.exists() and not path.is_file()): raise ValueError('Install destination is not a regular file') def install(item, home): base = Path(home)/'.local' appdir = base/'share/friendrewind-messenger' bindir = base/'bin' appsdir = base/'share/applications' for folder in (appdir, bindir, appsdir): folder.mkdir(parents=True, exist_ok=True) target = appdir/'messenger.AppImage' previous = appdir/'messenger.AppImage.previous' launcher = bindir/'friendrewind-messenger' desktop = appsdir/'friendrewind-messenger.desktop' for file in (target, previous, launcher, desktop): safe_file(file) print('Downloading FriendRewind Messenger', item['version'], '…', flush=True) fd, name = tempfile.mkstemp(prefix='.download-', dir=appdir) temp = Path(name) try: digest = hashlib.sha256(); size = 0 with os.fdopen(fd, 'wb') as out, fetch(item['url']) as response: while True: chunk = response.read(128*1024) if not chunk: break size += len(chunk) if size > item['size']: raise ValueError('Download exceeds the expected size') out.write(chunk); digest.update(chunk) out.flush(); os.fsync(out.fileno()) if size != item['size'] or digest.hexdigest() != item['sha256']: raise ValueError('Download failed its integrity check') os.chmod(temp, 0o755) if target.exists(): with tempfile.NamedTemporaryFile(prefix='.backup-', dir=appdir, delete=False) as out: backup = Path(out.name) try: with target.open('rb') as old: shutil.copyfileobj(old,out) out.flush(); os.fsync(out.fileno()) except Exception: backup.unlink(missing_ok=True); raise os.chmod(backup, 0o755); os.replace(backup, previous) os.replace(temp, target) # Avoid FUSE dependency on distributions without a compatible libfuse. import shlex runner = '#!/bin/sh\nexport APPIMAGE_EXTRACT_AND_RUN=1\nexec '+shlex.quote(str(target))+' "$@"\n' write_atomic(launcher,runner,0o755) escaped = str(launcher).replace('\\','\\\\').replace('"','\\"').replace('`','\\`').replace('$','\\$').replace('%','%%') entry = '[Desktop Entry]\nType=Application\nName=FriendRewind Messenger\nComment=Chat with your FriendRewind friends\nTerminal=false\nCategories=Network;InstantMessaging;\nIcon=internet-chat\nStartupWMClass=friendrewind-messenger\nExec="'+escaped+'"\n' write_atomic(desktop,entry,0o644) finally: temp.unlink(missing_ok=True) print('Installed. Open FriendRewind Messenger from your applications menu.') def write_atomic(target, content, mode): fd,name=tempfile.mkstemp(prefix='.friendrewind-',dir=target.parent) try: with os.fdopen(fd,'w',encoding='utf8') as out: out.write(content);out.flush();os.fsync(out.fileno()) os.chmod(name,mode);os.replace(name,target) finally: if os.path.exists(name): os.unlink(name) def main(): if platform.system() != 'Linux' or platform.machine() not in ('x86_64','amd64'): raise ValueError('This beta supports 64-bit Intel/AMD Linux computers') if os.geteuid() == 0: raise ValueError('Run this installer as your regular user, without sudo') if not shutil.which('openssl'): raise ValueError('OpenSSL is required to verify the installer') with fetch(FEED) as response: raw = response.read(MAX_FEED + 1) release=verify_feed(raw) install(release,Path.home()) if __name__ == '__main__': try: main() except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error: print('Installation failed:', str(error), file=sys.stderr);sys.exit(1)