From 0a2f31d4b39241a9fa471eff781147e8720caaa7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 28 Feb 2021 17:44:29 +0200 Subject: [PATCH] Add web-based login interface --- mautrix_facebook/__main__.py | 10 +- mautrix_facebook/commands/auth.py | 104 ++++++--- mautrix_facebook/config.py | 1 + mautrix_facebook/example-config.yaml | 2 + mautrix_facebook/web/public.py | 80 +++++-- .../web/static/lib/asn1hex-1.1.min.js | 7 + .../web/static/lib/htm-3.0.4.min.js | 1 + mautrix_facebook/web/static/lib/jsbn.min.js | 9 + .../web/static/lib/milligram-1.4.1.min.css | 8 + .../web/static/lib/normalize-8.0.1.min.css | 2 + .../web/static/lib/preact-10.5.12.min.js | 1 + mautrix_facebook/web/static/lib/rng.min.js | 9 + mautrix_facebook/web/static/lib/rsa.min.js | 12 ++ mautrix_facebook/web/static/lib/spinner.css | 80 +++++++ mautrix_facebook/web/static/login.html | 44 ++++ mautrix_facebook/web/static/login/api.js | 62 ++++++ mautrix_facebook/web/static/login/app.js | 200 ++++++++++++++++++ mautrix_facebook/web/static/login/crypto.js | 112 ++++++++++ mautrix_facebook/web/static/login/index.css | 10 + optional-requirements.txt | 3 + setup.py | 10 +- 21 files changed, 711 insertions(+), 56 deletions(-) create mode 100644 mautrix_facebook/web/static/lib/asn1hex-1.1.min.js create mode 100644 mautrix_facebook/web/static/lib/htm-3.0.4.min.js create mode 100644 mautrix_facebook/web/static/lib/jsbn.min.js create mode 100644 mautrix_facebook/web/static/lib/milligram-1.4.1.min.css create mode 100644 mautrix_facebook/web/static/lib/normalize-8.0.1.min.css create mode 100644 mautrix_facebook/web/static/lib/preact-10.5.12.min.js create mode 100644 mautrix_facebook/web/static/lib/rng.min.js create mode 100644 mautrix_facebook/web/static/lib/rsa.min.js create mode 100644 mautrix_facebook/web/static/lib/spinner.css create mode 100644 mautrix_facebook/web/static/login.html create mode 100644 mautrix_facebook/web/static/login/api.js create mode 100644 mautrix_facebook/web/static/login/app.js create mode 100644 mautrix_facebook/web/static/login/crypto.js create mode 100644 mautrix_facebook/web/static/login/index.css diff --git a/mautrix_facebook/__main__.py b/mautrix_facebook/__main__.py index 4d1e9c76..20f51d2d 100644 --- a/mautrix_facebook/__main__.py +++ b/mautrix_facebook/__main__.py @@ -13,6 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import Optional import asyncio import logging @@ -46,7 +47,7 @@ class MessengerBridge(Bridge): db: Database config: Config matrix: MatrixHandler - public_website: PublicBridgeWebsite + public_website: Optional[PublicBridgeWebsite] state_store: PgBridgeStateStore periodic_reconnect_task: asyncio.Task @@ -61,8 +62,11 @@ def prepare_db(self) -> None: def prepare_bridge(self) -> None: super().prepare_bridge() - self.public_website = PublicBridgeWebsite(self.config["appservice.public.shared_secret"]) - self.az.app.add_subapp(self.config["appservice.public.prefix"], self.public_website.app) + if self.config["appservice.public.enabled"]: + self.public_website = PublicBridgeWebsite(self.config["appservice.public.shared_secret"]) + self.az.app.add_subapp(self.config["appservice.public.prefix"], self.public_website.app) + else: + self.public_website = None def prepare_stop(self) -> None: self.periodic_reconnect_task.cancel() diff --git a/mautrix_facebook/commands/auth.py b/mautrix_facebook/commands/auth.py index 4fa58c63..1cb02367 100644 --- a/mautrix_facebook/commands/auth.py +++ b/mautrix_facebook/commands/auth.py @@ -14,11 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import asyncio +import time + +from yarl import URL from mautrix.client import Client from mautrix.errors import MForbidden from mautrix.bridge.commands import HelpSection, command_handler from mautrix.bridge import custom_puppet as cpu +from mautrix.util.signed_token import sign_token from maufbapi import AndroidState, AndroidAPI from maufbapi.http import TwoFactorRequired, OAuthException, IncorrectPassword @@ -28,48 +32,60 @@ SECTION_AUTH = HelpSection("Authentication", 10, "") - -async def check_approved_login(state: AndroidState, api: AndroidAPI, evt: CommandEvent) -> None: - while evt.sender.command_status and evt.sender.command_status["action"] == "Login": - await asyncio.sleep(5) - try: - was_approved = await api.check_approved_machine() - except Exception as e: - evt.log.exception("Error checking if login was approved from another device") - await evt.reply(f"Error checking if login was approved from another device: {e}") - break - if was_approved: - prev_cmd_status = evt.sender.command_status - evt.sender.command_status = None - try: - await api.login_approved() - except TwoFactorRequired: - await evt.reply("Login approved from another device, but Facebook decided that " - "you need to enter the 2FA code anyway.") - evt.sender.command_status = prev_cmd_status - return - await evt.sender.on_logged_in(state) - await evt.reply("Login successfully approved from another device") - break +web_unsupported = ("This instance of the Facebook bridge does not support " + "the web-based login interface") +alternative_web_login = ("Alternatively, you may use [the web-based login interface]({url}) " + "to prevent the bridge and homeserver from seeing your password") +forced_web_login = ("This instance of the Facebook bridge does not allow in-Matrix login. " + "Please use [the web-based login interface]({url}).") +send_password = "Please send your password here to log in" +missing_email = "Please use `$cmdprefix+sp login ` to log in here" -@command_handler(needs_auth=False, management_only=True, - help_section=SECTION_AUTH, help_text="Log in to Facebook", - help_args="<_email_> <_password_>") +@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, + help_text="Log in to Facebook", help_args="[_email_]") async def login(evt: CommandEvent) -> None: - if len(evt.args) < 2: - await evt.reply("Usage: `$cmdprefix+sp login `") + if evt.sender.client: + await evt.reply("You're already logged in") return - email, password = evt.args[0], " ".join(evt.args[1:]) + email = evt.args[0] if len(evt.args) > 0 else None + + if email: + evt.sender.command_status = { + "action": "Login", + "room_id": evt.room_id, + "next": enter_password, + "email": evt.args[0], + } + + if evt.bridge.public_website: + external_url = URL(evt.config["appservice.public.external"]) + token = sign_token(evt.bridge.public_website.secret_key, { + "mxid": evt.sender.mxid, + "expiry": int(time.time()) + 30 * 60, + }) + url = (external_url / "login.html").with_fragment(token) + if not evt.config["appservice.public.allow_matrix_login"]: + await evt.reply(forced_web_login.format(url=url)) + elif email: + await evt.reply(f"{send_password}. {alternative_web_login.format(url=url)}.") + else: + await evt.reply(f"{missing_email}. {alternative_web_login.format(url=url)}.") + elif not email: + await evt.reply(f"{missing_email}. {web_unsupported}.") + else: + await evt.reply(f"{send_password}. {web_unsupported}.") + + +async def enter_password(evt: CommandEvent) -> None: try: await evt.az.intent.redact(evt.room_id, evt.event_id) except MForbidden: pass - if evt.sender.client: - await evt.reply("You're already logged in") - return + email = evt.sender.command_status["email"] + password = evt.content.body state = AndroidState() state.generate(evt.sender.mxid) @@ -101,6 +117,30 @@ async def login(evt: CommandEvent) -> None: await evt.reply(f"Failed to log in: {e}") +async def check_approved_login(state: AndroidState, api: AndroidAPI, evt: CommandEvent) -> None: + while evt.sender.command_status and evt.sender.command_status["action"] == "Login": + await asyncio.sleep(5) + try: + was_approved = await api.check_approved_machine() + except Exception as e: + evt.log.exception("Error checking if login was approved from another device") + await evt.reply(f"Error checking if login was approved from another device: {e}") + break + if was_approved: + prev_cmd_status = evt.sender.command_status + evt.sender.command_status = None + try: + await api.login_approved() + except TwoFactorRequired: + await evt.reply("Login approved from another device, but Facebook decided that " + "you need to enter the 2FA code anyway.") + evt.sender.command_status = prev_cmd_status + return + await evt.sender.on_logged_in(state) + await evt.reply("Login successfully approved from another device") + break + + async def enter_2fa_code(evt: CommandEvent) -> None: checker_task: asyncio.Task = evt.sender.command_status["checker_task"] checker_task.cancel() diff --git a/mautrix_facebook/config.py b/mautrix_facebook/config.py index f7be8b06..29c31caf 100644 --- a/mautrix_facebook/config.py +++ b/mautrix_facebook/config.py @@ -57,6 +57,7 @@ def do_update(self, helper: ConfigUpdateHelper) -> None: base["appservice.public.shared_secret"] = self._new_token() else: copy("appservice.public.shared_secret") + copy("appservice.public.allow_matrix_login") copy("metrics.enabled") copy("metrics.listen_port") diff --git a/mautrix_facebook/example-config.yaml b/mautrix_facebook/example-config.yaml index c3207509..33582e31 100644 --- a/mautrix_facebook/example-config.yaml +++ b/mautrix_facebook/example-config.yaml @@ -46,6 +46,8 @@ appservice: # If set to "generate", a random string will be generated on the next startup. # If null, integration manager access to the API will not be possible. shared_secret: generate + # Allow logging in within Matrix. If false, users can only log in using the web interface. + allow_matrix_login: true # The unique ID of this appservice. id: facebook diff --git a/mautrix_facebook/web/public.py b/mautrix_facebook/web/public.py index 6ac6e264..f56c9e06 100644 --- a/mautrix_facebook/web/public.py +++ b/mautrix_facebook/web/public.py @@ -21,6 +21,7 @@ import json from aiohttp import web +import pkg_resources from mautrix.types import UserID from mautrix.util.signed_token import verify_token @@ -30,6 +31,10 @@ from .. import user as u, puppet as pu +class InvalidTokenError(Exception): + pass + + class PublicBridgeWebsite: log: logging.Logger = logging.getLogger("mau.web.public") app: web.Application @@ -40,10 +45,11 @@ def __init__(self, shared_secret: str) -> None: self.app = web.Application() self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64)) self.shared_secret = shared_secret - for path in ("whoami", "login", "login/2fa", "login/check_approved", "login/approved", - "logout", "disconnect", "reconnect", "refresh"): + for path in ("whoami", "login", "login/prepare", "login/2fa", "login/check_approved", + "login/approved", "logout", "disconnect", "reconnect", "refresh"): self.app.router.add_options(f"/api/{path}", self.login_options) self.app.router.add_get("/api/whoami", self.status) + self.app.router.add_post("/api/login/prepare", self.login_prepare) self.app.router.add_post("/api/login", self.login) self.app.router.add_post("/api/login/2fa", self.login_2fa) self.app.router.add_get("/api/login/check_approved", self.login_check_approved) @@ -52,12 +58,16 @@ def __init__(self, shared_secret: str) -> None: self.app.router.add_post("/api/disconnect", self.disconnect) self.app.router.add_post("/api/reconnect", self.reconnect) self.app.router.add_post("/api/refresh", self.refresh) + self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_facebook.web", + "static/")) - def verify_token(self, token: str) -> Optional[UserID]: + def verify_token(self, token: str) -> UserID: token = verify_token(self.secret_key, token) - if token and token.get("expiry", 0) > int(time.time()): + if token: + if token.get("expiry", 0) < int(time.time()): + raise InvalidTokenError("Access token has expired") return UserID(token.get("mxid")) - return None + raise InvalidTokenError("Access token is invalid") @property def _acao_headers(self) -> Dict[str, str]: @@ -94,9 +104,12 @@ async def check_token(self, request: web.Request) -> Optional['u.User']: raise web.HTTPBadRequest(text='{"error": "Missing user_id query param"}', headers=self._headers) else: - user_id = self.verify_token(token) - if not user_id: - raise web.HTTPForbidden(text='{"error": "Invalid token"}', headers=self._headers) + try: + user_id = self.verify_token(token) + except InvalidTokenError as e: + raise web.HTTPForbidden(text=json.dumps({"error": f"{e}, please request a new one" + " from the bridge bot"}), + headers=self._headers) user = await u.User.get_by_mxid(user_id) return user @@ -122,6 +135,28 @@ async def status(self, request: web.Request) -> web.Response: f"{user.state.device.name}") return web.json_response(data, headers=self._acao_headers) + async def login_prepare(self, request: web.Request) -> web.Response: + user = await self.check_token(request) + state = AndroidState() + state.generate(user.mxid) + api = AndroidAPI(state, log=user.log.getChild("login-api")) + user.command_status = { + "action": "Login", + "state": state, + "api": api, + } + try: + await api.mobile_config_sessionless() + except Exception as e: + self.log.exception("Failed to get mobile_config_sessionless to prepare login " + f"for {user.mxid}") + return web.json_response({"error": str(e)}, headers=self._acao_headers) + return web.json_response({ + "status": "login", + "password_encryption_key_id": state.session.password_encryption_key_id, + "password_encryption_pubkey": state.session.password_encryption_pubkey + }, headers=self._acao_headers) + async def login(self, request: web.Request) -> web.Response: user = await self.check_token(request) @@ -132,16 +167,33 @@ async def login(self, request: web.Request) -> web.Response: try: email = data["email"] + except KeyError: + raise web.HTTPBadRequest(text='{"error": "Missing email"}', headers=self._headers) + try: password = data["password"] + encrypted_password = None except KeyError: - raise web.HTTPBadRequest(text='{"error": "Missing keys"}', headers=self._headers) + try: + encrypted_password = data["encrypted_password"] + password = None + except KeyError: + raise web.HTTPBadRequest(text='{"error": "Missing password"}', + headers=self._headers) - state = AndroidState() - state.generate(user.mxid) - api = AndroidAPI(state, log=user.log.getChild("login-api")) - try: + if encrypted_password: + if not user.command_status or user.command_status["action"] != "Login": + raise web.HTTPBadRequest(text='{"error": "No login in progress"}', + headers=self._headers) + state: AndroidState = user.command_status["state"] + api: AndroidAPI = user.command_status["api"] + else: + state = AndroidState() + state.generate(user.mxid) + api = AndroidAPI(state, log=user.log.getChild("login-api")) await api.mobile_config_sessionless() - await api.login(email, password) + + try: + await api.login(email, password=password, encrypted_password=encrypted_password) await user.on_logged_in(state) return web.json_response({"status": "logged-in"}, headers=self._acao_headers) except TwoFactorRequired as e: diff --git a/mautrix_facebook/web/static/lib/asn1hex-1.1.min.js b/mautrix_facebook/web/static/lib/asn1hex-1.1.min.js new file mode 100644 index 00000000..ced8b9ea --- /dev/null +++ b/mautrix_facebook/web/static/lib/asn1hex-1.1.min.js @@ -0,0 +1,7 @@ +// asn1hex-1.2.8.js (c) 2012-2020 Kenji Urushima | kjur.github.com/jsrsasign/license + +import BigInteger from "./jsbn.min.js" + +var ASN1HEX=new function(){};ASN1HEX.getLblen=function(t,n){if("8"!=t.substr(n+2,1))return 1;var r=parseInt(t.substr(n+3,1));return 0==r?-1:0=e)break}return g},ASN1HEX.getNthChildIdx=function(t,n,r){return ASN1HEX.getChildIdx(t,n)[r]},ASN1HEX.getIdxbyList=function(t,n,r,e){var i,u,g=ASN1HEX;return 0==r.length?void 0!==e&&t.substr(n,2)!==e?-1:n:(i=r.shift())>=(u=g.getChildIdx(t,n)).length?-1:g.getIdxbyList(t,u[i],r,e)},ASN1HEX.getIdxbyListEx=function(t,n,r,e){var i,u,g=ASN1HEX;if(0==r.length)return void 0!==e&&t.substr(n,2)!==e?-1:n;i=r.shift(),u=g.getChildIdx(t,n);for(var s=0,o=0;o=t.length?null:i.getTLV(t,u)},ASN1HEX.getTLVbyListEx=function(t,n,r,e){var i=ASN1HEX,u=i.getIdxbyListEx(t,n,r,e);return-1==u?null:i.getTLV(t,u)},ASN1HEX.getVbyList=function(t,n,r,e,i){var u,g,s=ASN1HEX;return-1==(u=s.getIdxbyList(t,n,r,e))?null:u>=t.length?null:(g=s.getV(t,u),!0===i&&(g=g.substr(2)),g)},ASN1HEX.getVbyListEx=function(t,n,r,e,i){var u,g,s=ASN1HEX;return-1==(u=s.getIdxbyListEx(t,n,r,e))?null:(g=s.getV(t,u),"03"==t.substr(u,2)&&!1!==i&&(g=g.substr(2)),g)},ASN1HEX.getInt=function(t,n,r){null==r&&(r=-1);try{var e=t.substr(n,2);if("02"!=e&&"03"!=e)return r;var i=ASN1HEX.getV(t,n);return"02"==e?parseInt(i,16):bitstrtoint(i)}catch(t){return r}},ASN1HEX.getOID=function(t,n,r){null==r&&(r=null);try{if("06"!=t.substr(n,2))return r;var e=ASN1HEX.getV(t,n);return hextooid(e)}catch(t){return r}},ASN1HEX.getOIDName=function(t,n,r){null==r&&(r=null);try{var e=ASN1HEX.getOID(t,n,r);if(e==r)return r;var i=KJUR.asn1.x509.OID.oid2name(e);return""==i?e:i}catch(t){return r}},ASN1HEX.getString=function(t,n,r){null==r&&(r=null);try{var e=ASN1HEX.getV(t,n);return hextorstr(e)}catch(t){return r}},ASN1HEX.hextooidstr=function(t){var n=function(t,n){return t.length>=n?t:new Array(n-t.length+1).join("0")+t},r=[],e=t.substr(0,2),i=parseInt(e,16);r[0]=new String(Math.floor(i/40)),r[1]=new String(i%40);for(var u=t.substr(2),g=[],s=0;s0&&(l=l+"."+o.join(".")),l},ASN1HEX.dump=function(t,n,r,e){var i=ASN1HEX,u=i.getV,g=i.dump,s=i.getChildIdx,o=t;t instanceof KJUR.asn1.ASN1Object&&(o=t.getEncodedHex());var a=function(t,n){return t.length<=2*n?t:t.substr(0,n)+"..(total "+t.length/2+"bytes).."+t.substr(t.length-n,n)};void 0===n&&(n={ommit_long_octet:32}),void 0===r&&(r=0),void 0===e&&(e="");var l,f=n.ommit_long_octet;if("01"==(l=o.substr(r,2)))return"00"==(E=u(o,r))?e+"BOOLEAN FALSE\n":e+"BOOLEAN TRUE\n";if("02"==l)return e+"INTEGER "+a(E=u(o,r),f)+"\n";if("03"==l){var E=u(o,r);if(i.isASN1HEX(E.substr(2))){var h=e+"BITSTRING, encapsulates\n";return h+=g(E.substr(2),n,0,e+" ")}return e+"BITSTRING "+a(E,f)+"\n"}if("04"==l){E=u(o,r);if(i.isASN1HEX(E)){h=e+"OCTETSTRING, encapsulates\n";return h+=g(E,n,0,e+" ")}return e+"OCTETSTRING "+a(E,f)+"\n"}if("05"==l)return e+"NULL\n";if("06"==l){var S=u(o,r),N=KJUR.asn1.ASN1Util.oidHexToInt(S),A=KJUR.asn1.x509.OID.oid2name(N),b=N.replace(/\./g," ");return""!=A?e+"ObjectIdentifier "+A+" ("+b+")\n":e+"ObjectIdentifier ("+b+")\n"}if("0a"==l)return e+"ENUMERATED "+parseInt(u(o,r))+"\n";if("0c"==l)return e+"UTF8String '"+hextoutf8(u(o,r))+"'\n";if("13"==l)return e+"PrintableString '"+hextoutf8(u(o,r))+"'\n";if("14"==l)return e+"TeletexString '"+hextoutf8(u(o,r))+"'\n";if("16"==l)return e+"IA5String '"+hextoutf8(u(o,r))+"'\n";if("17"==l)return e+"UTCTime "+hextoutf8(u(o,r))+"\n";if("18"==l)return e+"GeneralizedTime "+hextoutf8(u(o,r))+"\n";if("1a"==l)return e+"VisualString '"+hextoutf8(u(o,r))+"'\n";if("1e"==l)return e+"BMPString '"+hextoutf8(u(o,r))+"'\n";if("30"==l){if("3000"==o.substr(r,4))return e+"SEQUENCE {}\n";h=e+"SEQUENCE\n";var c=n;if((2==(v=s(o,r)).length||3==v.length)&&"06"==o.substr(v[0],2)&&"04"==o.substr(v[v.length-1],2)){A=i.oidname(u(o,v[0]));var x=JSON.parse(JSON.stringify(n));x.x509ExtName=A,c=x}for(var H=0;H31)&&(128==(192&r)&&(31&r)==e))}catch(t){return!1}},ASN1HEX.isASN1HEX=function(t){var n=ASN1HEX;if(t.length%2==1)return!1;var r=n.getVblen(t,0),e=t.substr(0,2),i=n.getL(t,0);return t.length-e.length-i.length==2*r},ASN1HEX.checkStrictDER=function(t,n,r,e,i){var u=ASN1HEX;if(void 0===r){if("string"!=typeof t)throw new Error("not hex string");if(t=t.toLowerCase(),!KJUR.lang.String.isHex(t))throw new Error("not hex string");r=t.length,i=(e=t.length/2)<128?1:Math.ceil(e.toString(16))+1}if(u.getL(t,n).length>2*i)throw new Error("L of TLV too long: idx="+n);var g=u.getVblen(t,n);if(g>e)throw new Error("value of L too long than hex: idx="+n);var s=u.getTLV(t,n),o=s.length-2-u.getL(t,n).length;if(o!==2*g)throw new Error("V string length and L's value not the same:"+o+"/"+2*g);if(0===n&&t.length!=s.length)throw new Error("total length and TLV length unmatch:"+t.length+"!="+s.length);var a=t.substr(n,2);if("02"===a){var l=u.getVidx(t,n);if("00"==t.substr(l,2)&&t.charCodeAt(l+2)<56)throw new Error("not least zeros for DER INTEGER")}if(32&parseInt(a,16)){for(var f=u.getVblen(t,n),E=0,h=u.getChildIdx(t,n),S=0;S=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e=""},a=0;a"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0])}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]} diff --git a/mautrix_facebook/web/static/lib/jsbn.min.js b/mautrix_facebook/web/static/lib/jsbn.min.js new file mode 100644 index 00000000..776f93cd --- /dev/null +++ b/mautrix_facebook/web/static/lib/jsbn.min.js @@ -0,0 +1,9 @@ +// From http://www-cs-students.stanford.edu/~tjw/jsbn/jsbn.js +// Also includes bnIntValue from http://www-cs-students.stanford.edu/~tjw/jsbn/jsbn2.js + +// Copyright (c) 2005 Tom Wu +// http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE + +var dbits,canary=0xdeadbeefcafe,j_lm=15715070==(16777215&canary);function BigInteger(t,i,r){null!=t&&("number"==typeof t?this.fromNumber(t,i,r):null==i&&"string"!=typeof t?this.fromString(t,256):this.fromString(t,i))}function nbi(){return new BigInteger(null)}function am1(t,i,r,o,n,e){for(;--e>=0;){var s=i*this[t++]+r[o]+n;n=Math.floor(s/67108864),r[o++]=67108863&s}return n}function am2(t,i,r,o,n,e){for(var s=32767&i,h=i>>15;--e>=0;){var p=32767&this[t],a=this[t++]>>15,f=h*p+a*s;n=((p=s*p+((32767&f)<<15)+r[o]+(1073741823&n))>>>30)+(f>>>15)+h*a+(n>>>30),r[o++]=1073741823&p}return n}function am3(t,i,r,o,n,e){for(var s=16383&i,h=i>>14;--e>=0;){var p=16383&this[t],a=this[t++]>>14,f=h*p+a*s;n=((p=s*p+((16383&f)<<14)+r[o]+n)>>28)+(f>>14)+h*a,r[o++]=268435455&p}return n}j_lm&&"Microsoft Internet Explorer"==navigator.appName?(BigInteger.prototype.am=am2,dbits=30):j_lm&&"Netscape"!=navigator.appName?(BigInteger.prototype.am=am1,dbits=26):(BigInteger.prototype.am=am3,dbits=28),BigInteger.prototype.DB=dbits,BigInteger.prototype.DM=(1<=0;--i)t[i]=this[i];t.t=this.t,t.s=this.s}function bnpFromInt(t){this.t=1,this.s=t<0?-1:0,t>0?this[0]=t:t<-1?this[0]=t+this.DV:this.t=0}function nbv(t){var i=nbi();return i.fromInt(t),i}function bnpFromString(t,i){var r;if(16==i)r=4;else if(8==i)r=3;else if(256==i)r=8;else if(2==i)r=1;else if(32==i)r=5;else{if(4!=i)return void this.fromRadix(t,i);r=2}this.t=0,this.s=0;for(var o=t.length,n=!1,e=0;--o>=0;){var s=8==r?255&t[o]:intAt(t,o);s<0?"-"==t.charAt(o)&&(n=!0):(n=!1,0==e?this[this.t++]=s:e+r>this.DB?(this[this.t-1]|=(s&(1<>this.DB-e):this[this.t-1]|=s<=this.DB&&(e-=this.DB))}8==r&&0!=(128&t[0])&&(this.s=-1,e>0&&(this[this.t-1]|=(1<0&&this[this.t-1]==t;)--this.t}function bnToString(t){if(this.s<0)return"-"+this.negate().toString(t);var i;if(16==t)i=4;else if(8==t)i=3;else if(2==t)i=1;else if(32==t)i=5;else{if(4!=t)return this.toRadix(t);i=2}var r,o=(1<0)for(h>h)>0&&(n=!0,e=int2char(r));s>=0;)h>(h+=this.DB-i)):(r=this[s]>>(h-=i)&o,h<=0&&(h+=this.DB,--s)),r>0&&(n=!0),n&&(e+=int2char(r));return n?e:"0"}function bnNegate(){var t=nbi();return BigInteger.ZERO.subTo(this,t),t}function bnAbs(){return this.s<0?this.negate():this}function bnCompareTo(t){var i=this.s-t.s;if(0!=i)return i;var r=this.t;if(0!=(i=r-t.t))return this.s<0?-i:i;for(;--r>=0;)if(0!=(i=this[r]-t[r]))return i;return 0}function nbits(t){var i,r=1;return 0!=(i=t>>>16)&&(t=i,r+=16),0!=(i=t>>8)&&(t=i,r+=8),0!=(i=t>>4)&&(t=i,r+=4),0!=(i=t>>2)&&(t=i,r+=2),0!=(i=t>>1)&&(t=i,r+=1),r}function bnBitLength(){return this.t<=0?0:this.DB*(this.t-1)+nbits(this[this.t-1]^this.s&this.DM)}function bnpDLShiftTo(t,i){var r;for(r=this.t-1;r>=0;--r)i[r+t]=this[r];for(r=t-1;r>=0;--r)i[r]=0;i.t=this.t+t,i.s=this.s}function bnpDRShiftTo(t,i){for(var r=t;r=0;--r)i[r+s+1]=this[r]>>n|h,h=(this[r]&e)<=0;--r)i[r]=0;i[s]=h,i.t=this.t+s+1,i.s=this.s,i.clamp()}function bnpRShiftTo(t,i){i.s=this.s;var r=Math.floor(t/this.DB);if(r>=this.t)i.t=0;else{var o=t%this.DB,n=this.DB-o,e=(1<>o;for(var s=r+1;s>o;o>0&&(i[this.t-r-1]|=(this.s&e)<>=this.DB;if(t.t>=this.DB;o+=this.s}else{for(o+=this.s;r>=this.DB;o-=t.s}i.s=o<0?-1:0,o<-1?i[r++]=this.DV+o:o>0&&(i[r++]=o),i.t=r,i.clamp()}function bnpMultiplyTo(t,i){var r=this.abs(),o=t.abs(),n=r.t;for(i.t=n+o.t;--n>=0;)i[n]=0;for(n=0;n=0;)t[r]=0;for(r=0;r=i.DV&&(t[r+i.t]-=i.DV,t[r+i.t+1]=1)}t.t>0&&(t[t.t-1]+=i.am(r,i[r],t,2*r,0,1)),t.s=0,t.clamp()}function bnpDivRemTo(t,i,r){var o=t.abs();if(!(o.t<=0)){var n=this.abs();if(n.t0?(o.lShiftTo(p,e),n.lShiftTo(p,r)):(o.copyTo(e),n.copyTo(r));var a=e.t,f=e[a-1];if(0!=f){var u=f*(1<1?e[a-2]>>this.F2:0),g=this.FV/u,m=(1<=0&&(r[r.t++]=1,r.subTo(l,r)),BigInteger.ONE.dlShiftTo(a,l),l.subTo(e,e);e.t=0;){var B=r[--v]==f?this.DM:Math.floor(r[v]*g+(r[v-1]+c)*m);if((r[v]+=e.am(0,B,r,b,0,a))0&&r.rShiftTo(p,r),s<0&&BigInteger.ZERO.subTo(r,r)}}}function bnMod(t){var i=nbi();return this.abs().divRemTo(t,null,i),this.s<0&&i.compareTo(BigInteger.ZERO)>0&&t.subTo(i,i),i}function Classic(t){this.m=t}function cConvert(t){return t.s<0||t.compareTo(this.m)>=0?t.mod(this.m):t}function cRevert(t){return t}function cReduce(t){t.divRemTo(this.m,null,t)}function cMulTo(t,i,r){t.multiplyTo(i,r),this.reduce(r)}function cSqrTo(t,i){t.squareTo(i),this.reduce(i)}function bnpInvDigit(){if(this.t<1)return 0;var t=this[0];if(0==(1&t))return 0;var i=3&t;return(i=(i=(i=(i=i*(2-(15&t)*i)&15)*(2-(255&t)*i)&255)*(2-((65535&t)*i&65535))&65535)*(2-t*i%this.DV)%this.DV)>0?this.DV-i:-i}function Montgomery(t){this.m=t,this.mp=t.invDigit(),this.mpl=32767&this.mp,this.mph=this.mp>>15,this.um=(1<0&&this.m.subTo(i,i),i}function montRevert(t){var i=nbi();return t.copyTo(i),this.reduce(i),i}function montReduce(t){for(;t.t<=this.mt2;)t[t.t++]=0;for(var i=0;i>15)*this.mpl&this.um)<<15)&t.DM;for(t[r=i+this.m.t]+=this.m.am(0,o,t,i,0,this.m.t);t[r]>=t.DV;)t[r]-=t.DV,t[++r]++}t.clamp(),t.drShiftTo(this.m.t,t),t.compareTo(this.m)>=0&&t.subTo(this.m,t)}function montSqrTo(t,i){t.squareTo(i),this.reduce(i)}function montMulTo(t,i,r){t.multiplyTo(i,r),this.reduce(r)}function bnpIsEven(){return 0==(this.t>0?1&this[0]:this.s)}function bnpExp(t,i){if(t>4294967295||t<1)return BigInteger.ONE;var r=nbi(),o=nbi(),n=i.convert(this),e=nbits(t)-1;for(n.copyTo(r);--e>=0;)if(i.sqrTo(r,o),(t&1<0)i.mulTo(o,n,r);else{var s=r;r=o,o=s}return i.revert(r)}function bnModPowInt(t,i){var r;return r=t<256||i.isEven()?new Classic(i):new Montgomery(i),this.exp(t,r)}function bnIntValue(){if(this.s<0){if(1==this.t)return this[0]-this.DV;if(0==this.t)return-1}else{if(1==this.t)return this[0];if(0==this.t)return 0}return(this[1]&(1<<32-this.DB)-1)<code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:.1rem solid #f4f5f6;margin:3rem 0}input:not([type]),input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=url],input[type=week],select,textarea{-webkit-appearance:none;background-color:transparent;border:.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1rem .7rem;width:100%}input:not([type]):focus,input[type=color]:focus,input[type=date]:focus,input[type=datetime-local]:focus,input[type=datetime]:focus,input[type=email]:focus,input[type=month]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=url]:focus,input[type=week]:focus,select:focus,textarea:focus{border-color:#9b4dca;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}select[multiple]{background:0 0;height:auto}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type=checkbox],input[type=radio]{display:inline}.label-inline{display:inline-block;font-weight:400;margin-left:.5rem}.container{margin:0 auto;max-width:112rem;padding:0 2rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{align-self:center}@media (min-width:40rem){.row{flex-direction:row;margin-left:-1rem;width:calc(100% + 2rem)}.row .column{margin-bottom:inherit;padding:0 1rem}}a{color:#9b4dca;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width:40rem){table{display:table;overflow-x:initial}}b,strong{font-weight:700}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} diff --git a/mautrix_facebook/web/static/lib/normalize-8.0.1.min.css b/mautrix_facebook/web/static/lib/normalize-8.0.1.min.css new file mode 100644 index 00000000..b52daa7b --- /dev/null +++ b/mautrix_facebook/web/static/lib/normalize-8.0.1.min.css @@ -0,0 +1,2 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ +html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none} diff --git a/mautrix_facebook/web/static/lib/preact-10.5.12.min.js b/mautrix_facebook/web/static/lib/preact-10.5.12.min.js new file mode 100644 index 00000000..b4ff7419 --- /dev/null +++ b/mautrix_facebook/web/static/lib/preact-10.5.12.min.js @@ -0,0 +1 @@ +var n,l,u,i,t,r,o={},f=[],e=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function c(e,n){for(var t in n)e[t]=n[t];return e}function s(e){var n=e.parentNode;n&&n.removeChild(e)}function a(e,n,t){var _,l,o,r=arguments,u={};for(o in n)"key"==o?_=n[o]:"ref"==o?l=n[o]:u[o]=n[o];if(arguments.length>3)for(t=[t],o=3;o0?v(m.type,m.props,m.key,null,m.__v):m)){if(m.__=t,m.__b=t.__b+1,null===(h=P[p])||h&&m.key==h.key&&m.type===h.type)P[p]=void 0;else for(a=0;a3)for(t=[t],o=3;o>8&255,rng_pool[rng_pptr++]^=t>>16&255,rng_pool[rng_pptr++]^=t>>24&255,rng_pptr>=rng_psize&&(rng_pptr-=rng_psize)}function rng_seed_time(){rng_seed_int((new Date).getTime())}if(null==rng_pool){var t;if(rng_pool=new Array,rng_pptr=0,window.crypto&&window.crypto.getRandomValues){var ua=new Uint8Array(32);for(window.crypto.getRandomValues(ua),t=0;t<32;++t)rng_pool[rng_pptr++]=ua[t]}if("Netscape"==navigator.appName&&navigator.appVersion<"5"&&window.crypto){var z=window.crypto.random(32);for(t=0;t>>8,rng_pool[rng_pptr++]=255&t;rng_pptr=0,rng_seed_time()}function rng_get_byte(){if(null==rng_state){for(rng_seed_time(),(rng_state=prng_newstate()).init(rng_pool),rng_pptr=0;rng_pptr=0&&t>0;)e[--t]=n[r--];e[--t]=0;for(var l=new SecureRandom,i=new Array;t>2;){for(i[0]=0;0==i[0];)l.nextBytes(i);e[--t]=i[0]}return e[--t]=2,e[--t]=0,new BigInteger(e)}function RSAKey(){this.n=null,this.e=0,this.d=null,this.p=null,this.q=null,this.dmp1=null,this.dmq1=null,this.coeff=null}function RSASetPublic(n,t){null!=n&&null!=t&&n.length>0&&t.length>0?(this.n=parseBigInt(n,16),this.e=parseInt(t,16)):alert("Invalid RSA public key")}function RSADoPublic(n){return n.modPowInt(this.e,this.n)}function RSAEncrypt(n){var t=pkcs1pad2(n,this.n.bitLength()+7>>3);if(null==t)return null;var e=this.doPublic(t);if(null==e)return null;var r=e.toString(16);return 0==(1&r.length)?r:"0"+r}RSAKey.prototype.doPublic=RSADoPublic,RSAKey.prototype.setPublic=RSASetPublic,RSAKey.prototype.encrypt=RSAEncrypt; + +export default RSAKey diff --git a/mautrix_facebook/web/static/lib/spinner.css b/mautrix_facebook/web/static/lib/spinner.css new file mode 100644 index 00000000..ca0e3356 --- /dev/null +++ b/mautrix_facebook/web/static/lib/spinner.css @@ -0,0 +1,80 @@ +.loader { + color: #9b4dca; + font-size: 90px; + text-indent: -9999em; + overflow: hidden; + width: 1em; + height: 1em; + border-radius: 50%; + margin: 72px auto; + position: relative; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load6 1.7s infinite ease, round 1.7s infinite ease; + animation: load6 1.7s infinite ease, round 1.7s infinite ease; +} +@-webkit-keyframes load6 { + 0% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } + 5%, + 95% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } + 10%, + 59% { + box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em; + } + 20% { + box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em; + } + 38% { + box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em; + } + 100% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } +} +@keyframes load6 { + 0% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } + 5%, + 95% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } + 10%, + 59% { + box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em; + } + 20% { + box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em; + } + 38% { + box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em; + } + 100% { + box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em; + } +} +@-webkit-keyframes round { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes round { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/mautrix_facebook/web/static/login.html b/mautrix_facebook/web/static/login.html new file mode 100644 index 00000000..df5ddfb9 --- /dev/null +++ b/mautrix_facebook/web/static/login.html @@ -0,0 +1,44 @@ + + + + + + + mautrix-facebook login + + + + + + + + + + + + + + + + + + + + + diff --git a/mautrix_facebook/web/static/login/api.js b/mautrix_facebook/web/static/login/api.js new file mode 100644 index 00000000..875fb2e3 --- /dev/null +++ b/mautrix_facebook/web/static/login/api.js @@ -0,0 +1,62 @@ +// mautrix-facebook - A Matrix-Facebook Messenger puppeting bridge. +// Copyright (C) 2021 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import encryptPassword from "./crypto.js" + +const apiToken = location.hash.slice(1) +const headers = { Authorization: `Bearer ${apiToken}` } +const jsonHeaders = { ...headers, "Content-Type": "application/json" } +const fetchParams = { headers } + +export async function whoami() { + const resp = await fetch("api/whoami", fetchParams) + return await resp.json() +} + +export async function prepareLogin() { + const resp = await fetch("api/login/prepare", { ...fetchParams, method: "POST" }) + return await resp.json() +} + +export async function login(pubkey, keyID, email, password) { + const resp = await fetch("api/login", { + method: "POST", + body: JSON.stringify({ + email, + encrypted_password: await encryptPassword(pubkey, keyID, password), + }), + headers: jsonHeaders, + }) + return await resp.json() +} + +export async function login2FA(email, code) { + const resp = await fetch("api/login/2fa", { + method: "POST", + body: JSON.stringify({ email, code }), + headers: jsonHeaders, + }) + return await resp.json() +} + +export async function loginApproved() { + const resp = await fetch("api/login/approved", { method: "POST", headers }) + return await resp.json() +} + +export async function wasLoginApproved() { + const resp = await fetch("api/login/check_approved", fetchParams) + return (await resp.json()).approved +} diff --git a/mautrix_facebook/web/static/login/app.js b/mautrix_facebook/web/static/login/app.js new file mode 100644 index 00000000..6e4565fd --- /dev/null +++ b/mautrix_facebook/web/static/login/app.js @@ -0,0 +1,200 @@ +// mautrix-facebook - A Matrix-Facebook Messenger puppeting bridge. +// Copyright (C) 2021 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { h, Component, render } from "../lib/preact-10.5.12.min.js" +import htm from "../lib/htm-3.0.4.min.js" +import * as api from "./api.js" + +const html = htm.bind(h) + +class App extends Component { + constructor(props) { + super(props) + this.approveCheckInterval = null + this.state = { + loading: true, + submitting: false, + error: null, + mxid: null, + facebook: null, + status: "pre-login", + pubkey: null, + keyID: null, + email: "", + password: "", + twoFactorCode: "", + twoFactorInfo: {}, + } + } + + async componentDidMount() { + const { error, mxid, facebook } = await api.whoami() + if (error) { + this.setState({ error, loading: false }) + } else { + this.setState({ mxid, facebook, loading: false }) + } + } + + checkLoginApproved = async () => { + if (!await api.wasLoginApproved()) { + return + } + clearInterval(this.approveCheckInterval) + this.approveCheckInterval = null + const resp = await api.loginApproved() + if (resp.status === "logged-in") { + this.setState({ status: resp.status }) + } + } + + submitNoDefault = evt => { + evt.preventDefault() + this.submit() + } + + async submit() { + if (this.approveCheckInterval) { + clearInterval(this.approveCheckInterval) + this.approveCheckInterval = null + } + this.setState({ submitting: true }) + let resp + switch (this.state.status) { + case "pre-login": + resp = await api.prepareLogin() + break + case "login": + resp = await api.login(this.state.pubkey, this.state.keyID, + this.state.email, this.state.password) + break + case "two-factor": + resp = await api.login2FA(this.state.email, this.state.twoFactorCode) + break + } + const stateUpdate = { submitting: false } + if (typeof resp.error === "string") { + stateUpdate.error = resp.error + } else { + stateUpdate.status = resp.status + } + if (resp.password_encryption_key_id) { + stateUpdate.pubkey = resp.password_encryption_pubkey + stateUpdate.keyID = resp.password_encryption_key_id + } + if (resp.status === "two-factor") { + this.approveCheckInterval = setInterval(this.checkLoginApproved, 5000) + stateUpdate.twoFactorInfo = resp.error + } else if (resp.status === "logged-in") { + api.whoami().then(({ facebook }) => this.setState({ facebook })) + } + this.setState(stateUpdate) + } + + fieldChange = evt => { + this.setState({ [evt.target.id]: evt.target.value }) + } + + renderFields() { + switch (this.state.status) { + case "pre-login": + return null + case "login": + return html` + + + + + ` + case "two-factor": + return html` +

${this.state.twoFactorInfo.error_user_msg}

+ + + + + ` + } + } + + submitButtonText() { + switch (this.state.status) { + case "pre-login": + return "Start" + case "login": + case "two-factor": + return "Sign in" + } + } + + renderContent() { + if (this.state.loading) { + return html` +
Loading...
+ ` + } else if (this.state.status === "logged-in") { + if (this.state.facebook) { + return html` + Successfully logged in as ${this.state.facebook.name}. The bridge will appear + as ${this.state.facebook.device_displayname} in Facebook security settings. + ` + } + return html` + Successfully logged in + ` + } else if (this.state.facebook) { + return html` + You're already logged in as ${this.state.facebook.name}. The bridge appears + as ${this.state.facebook.device_displayname} in Facebook security settings. + ` + } + return html` + ${this.state.error && html` +
${this.state.error}
+ `} +
+
+ + + ${this.renderFields()} + +
+
+ ` + } + + render() { + return html` +
+

mautrix-facebook login

+ ${this.renderContent()} +
+ ` + } +} + + +render(html` + <${App}/> +`, document.body) diff --git a/mautrix_facebook/web/static/login/crypto.js b/mautrix_facebook/web/static/login/crypto.js new file mode 100644 index 00000000..158ac7fe --- /dev/null +++ b/mautrix_facebook/web/static/login/crypto.js @@ -0,0 +1,112 @@ +// mautrix-facebook - A Matrix-Facebook Messenger puppeting bridge. +// Copyright (C) 2021 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// We have to use this pure-js RSA implementation because SubtleCrypto dropped PKCS#1 v1.5 support. +import RSAKey from "../lib/rsa.min.js" +import ASN1HEX from "../lib/asn1hex-1.1.min.js" + +function pemToHex(pem) { + // Strip pem header + pem = pem.replace("-----BEGIN PUBLIC KEY-----", "") + pem = pem.replace("-----END PUBLIC KEY-----", "") + + // Convert base64 to hex + const raw = atob(pem) + let result = "" + for (let i = 0; i < raw.length; i++) { + const hex = raw.charCodeAt(i).toString(16) + result += (hex.length === 2 ? hex : "0" + hex) + } + return result.toLowerCase() +} + +function getKey(pem) { + const keyHex = pemToHex(pem) + if (ASN1HEX.isASN1HEX(keyHex) === false) { + throw new Error("key is not ASN.1 hex string") + } else if (ASN1HEX.getVbyList(keyHex, 0, [0, 0], "06") !== "2a864886f70d010101") { + throw new Error("not PKCS8 RSA key") + } else if (ASN1HEX.getTLVbyListEx(keyHex, 0, [0, 0]) !== "06092a864886f70d010101") { + throw new Error("not PKCS8 RSA public key") + } + + const p5hex = ASN1HEX.getTLVbyListEx(keyHex, 0, [1, 0]) + if (ASN1HEX.isASN1HEX(p5hex) === false) { + throw new Error("keyHex is not ASN.1 hex string") + } + + const aIdx = ASN1HEX.getChildIdx(p5hex, 0) + if (aIdx.length !== 2 || p5hex.substr(aIdx[0], 2) !== "02" || p5hex.substr(aIdx[1], 2) !== "02") { + throw new Error("wrong hex for PKCS#5 public key") + } + + const hN = ASN1HEX.getV(p5hex, aIdx[0]) + const hE = ASN1HEX.getV(p5hex, aIdx[1]) + const key = new RSAKey() + key.setPublic(hN, hE) + return key +} + +// encryptPassword encrypts a login password using AES-256-GCM, then encrypts the AES key +// for Facebook's RSA-2048 key using PKCS#1 v1.5 padding. +// +// See https://github.com/tulir/mautrix-facebook/blob/v0.2.0/maufbapi/http/login.py#L164-L192 +// for the Python implementation of the same encryption protocol. +async function encryptPassword(pubkey, keyID, password) { + // Key and IV for AES encryption + const aesKey = await crypto.subtle.generateKey({ + name: "AES-GCM", + length: 256, + }, true, ["encrypt", "decrypt"]) + const aesIV = crypto.getRandomValues(new Uint8Array(12)) + // Get the actual bytes of the AES key + const aesKeyBytes = await crypto.subtle.exportKey("raw", aesKey) + + // Encrypt AES key with Facebook's RSA public key. + const rsaKey = getKey(pubkey) + const encryptedAESKeyHex = rsaKey.encrypt(new Uint8Array(aesKeyBytes)) + const encryptedAESKey = new Uint8Array(encryptedAESKeyHex.match(/[0-9A-Fa-f]{2}/g).map(h => parseInt(h, 16))) + + const encoder = new TextEncoder() + const time = Math.floor(Date.now() / 1000) + // Encrypt the password. The result includes the ciphertext and AES MAC auth tag. + const encryptedPasswordBuffer = await crypto.subtle.encrypt({ + name: "AES-GCM", + iv: aesIV, + // Add the current time to the additional authenticated data (AAD) section + additionalData: encoder.encode(time.toString()), + tagLength: 128, + }, aesKey, encoder.encode(password)) + // SubtleCrypto returns the auth tag and ciphertext in the wrong order, + // so we have to flip them around. + const authTag = new Uint8Array(encryptedPasswordBuffer.slice(-16)) + const encryptedPassword = new Uint8Array(encryptedPasswordBuffer.slice(0, -16)) + + const payload = new Uint8Array(2 + aesIV.byteLength + 2 + encryptedAESKey.byteLength + authTag.byteLength + encryptedPassword.byteLength) + // 1 is presumably the version + payload[0] = 1 + payload[1] = keyID + payload.set(aesIV, 2) + // Length of the encrypted AES key as a little-endian 16-bit int + payload[aesIV.byteLength + 2] = encryptedAESKey.byteLength & (1 << 8) + payload[aesIV.byteLength + 3] = encryptedAESKey.byteLength >> 8 + payload.set(encryptedAESKey, 4 + aesIV.byteLength) + payload.set(authTag, 4 + aesIV.byteLength + encryptedAESKey.byteLength) + payload.set(encryptedPassword, 4 + aesIV.byteLength + encryptedAESKey.byteLength + authTag.byteLength) + return `#PWD_MSGR:1:${time}:${btoa(String.fromCharCode(...payload))}` +} + +export default encryptPassword diff --git a/mautrix_facebook/web/static/login/index.css b/mautrix_facebook/web/static/login/index.css new file mode 100644 index 00000000..efef17d1 --- /dev/null +++ b/mautrix_facebook/web/static/login/index.css @@ -0,0 +1,10 @@ +.error { + background-color: darkred !important; + border-color: darkred !important; + opacity: 1 !important; +} + +main { + max-width: 50rem; + margin: 2rem auto 0; +} diff --git a/optional-requirements.txt b/optional-requirements.txt index c8e71c1c..3054544f 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -10,3 +10,6 @@ unpaddedbase64>=1,<2 #/metrics prometheus_client>=0.6,<0.10 + +#/weblogin +setuptools diff --git a/setup.py b/setup.py index 810acc35..95df46ca 100644 --- a/setup.py +++ b/setup.py @@ -63,13 +63,9 @@ "Programming Language :: Python :: 3.9", ], package_data={ - "mautrix_facebook": [ -# "web/static/*", - "example-config.yaml", - ], - "maufbapi.mqtt": [ - "topics.json", - ], + "mautrix_facebook": ["example-config.yaml"], + "mautrix_facebook.web": ["static/*", "static/**/*"], + "maufbapi.mqtt": ["topics.json"], }, data_files=[ (".", ["alembic.ini", "mautrix_facebook/example-config.yaml"]),