Remove incomplete web-based login for now

This commit is contained in:
Andrew Ferrazzutti 2022-04-10 23:40:11 -04:00
parent 68e2f70371
commit 0b23efd43a
20 changed files with 26 additions and 971 deletions

View File

@ -28,7 +28,7 @@ from .puppet import Puppet
from .user import User
from .kt.client import Client as KakaoTalkClient
from .version import linkified_version, version
from .web import PublicBridgeWebsite
#from .web import PublicBridgeWebsite
from . import commands as _
@ -46,7 +46,7 @@ class KakaoTalkBridge(Bridge):
config: Config
matrix: MatrixHandler
public_website: PublicBridgeWebsite | None
#public_website: PublicBridgeWebsite | None
def prepare_config(self)->None:
super().prepare_config()
@ -55,9 +55,9 @@ class KakaoTalkBridge(Bridge):
super().prepare_db()
init_db(self.db)
""" TODO Implement web login
def prepare_bridge(self) -> None:
super().prepare_bridge()
""" TODO Implement web login
if self.config["appservice.public.enabled"]:
secret = self.config["appservice.public.shared_secret"]
self.public_website = PublicBridgeWebsite(loop=self.loop, shared_secret=secret)
@ -67,7 +67,6 @@ class KakaoTalkBridge(Bridge):
else:
self.public_website = None
"""
self.public_website = None
def prepare_stop(self) -> None:
self.log.debug("Stopping RPC connection")
@ -87,8 +86,10 @@ class KakaoTalkBridge(Bridge):
if self.config["bridge.resend_bridge_info"]:
self.add_startup_actions(self.resend_bridge_info())
await super().start()
""" TODO Implement web login
if self.public_website:
self.public_website.ready_wait.set_result(None)
"""
async def resend_bridge_info(self) -> None:
self.config["bridge.resend_bridge_info"] = False

View File

@ -13,13 +13,13 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import time
#import time
from yarl import URL
#from yarl import URL
from mautrix.bridge.commands import HelpSection, command_handler
from mautrix.errors import MForbidden
from mautrix.util.signed_token import sign_token
#from mautrix.util.signed_token import sign_token
from ..kt.client import Client as KakaoTalkClient
from ..kt.client.errors import DeviceVerificationRequired, IncorrectPasscode, IncorrectPassword, CommandException
@ -29,6 +29,7 @@ from .typehint import CommandEvent
SECTION_AUTH = HelpSection("Authentication", 10, "")
""" TODO Implement web login
web_unsupported = (
"This instance of the KakaoTalk bridge does not support the web-based login interface"
)
@ -40,6 +41,7 @@ forced_web_login = (
"This instance of the KakaoTalk 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 <email>` to log in here"
try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up."
@ -67,6 +69,7 @@ async def login(evt: CommandEvent) -> None:
"email": evt.args[0],
}
""" TODO Implement web login
if evt.bridge.public_website:
external_url = URL(evt.config["appservice.public.external"])
token = sign_token(
@ -87,6 +90,11 @@ async def login(evt: CommandEvent) -> None:
await evt.reply(f"{missing_email}. {web_unsupported}.")
else:
await evt.reply(f"{send_password}. {web_unsupported}.")
"""
if not email:
await evt.reply(f"{missing_email}.")
else:
await evt.reply(f"{send_password}.")
async def enter_password(evt: CommandEvent) -> None:

View File

@ -35,11 +35,13 @@ class Config(BaseBridgeConfig):
return [
*super().forbidden_defaults,
ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"),
ForbiddenDefault(
"appservice.public.external",
"https://example.com/public",
condition="appservice.public.enabled",
),
# TODO
#ForbiddenDefault(
# "appservice.public.external",
# "https://example.com/public",
# condition="appservice.public.enabled",
#),
#
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
]
@ -50,6 +52,7 @@ class Config(BaseBridgeConfig):
copy("homeserver.asmux")
""" TODO
copy("appservice.public.enabled")
copy("appservice.public.prefix")
copy("appservice.public.external")
@ -59,6 +62,7 @@ class Config(BaseBridgeConfig):
copy("appservice.public.shared_secret")
copy("appservice.public.allow_matrix_login")
copy("appservice.public.segment_key")
"""
copy("metrics.enabled")
copy("metrics.listen_port")

View File

@ -49,26 +49,6 @@ appservice:
min_size: 5
max_size: 10
# Public part of web server for out-of-Matrix interaction with the bridge.
# TODO Implement web login
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: false
# The prefix to use in the public-facing endpoints.
prefix: /public
# The base URL where the public-facing endpoints are available. The prefix is not added
# implicitly.
external: https://example.com/public
# Shared secret for integration managers such as mautrix-manager.
# 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
# Segment API key to enable analytics tracking for web server endpoints. Set to null to disable.
# Currently the only events are login start, success and fail.
segment_key: null
# The unique ID of this appservice.
id: kakaotalk
# Username of the appservice bot.

View File

@ -1 +0,0 @@
from .public import PublicBridgeWebsite

View File

@ -1,380 +0,0 @@
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
#
# 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
import json
import logging
import random
import string
import time
from aiohttp import web
import pkg_resources
from mautrix.types import UserID
from mautrix.util.signed_token import verify_token
from .. import puppet as pu, user as u
class InvalidTokenError(Exception):
pass
class PublicBridgeWebsite:
log: logging.Logger = logging.getLogger("mau.web.public")
app: web.Application
secret_key: str
shared_secret: str
ready_wait: asyncio.Future | None
def __init__(self, shared_secret: str, loop: asyncio.AbstractEventLoop) -> None:
self.app = web.Application()
self.ready_wait = loop.create_future()
self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
self.shared_secret = shared_secret
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)
self.app.router.add_post("/api/login/approved", self.login_approved)
self.app.router.add_post("/api/logout", self.logout)
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("matrix_appservice_kakaotalk.web", "static/")
)
def verify_token(self, token: str) -> UserID:
token = verify_token(self.secret_key, token)
if token:
if token.get("expiry", 0) < int(time.time()):
raise InvalidTokenError("Access token has expired")
return UserID(token.get("mxid"))
raise InvalidTokenError("Access token is invalid")
@property
def _acao_headers(self) -> dict[str, str]:
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
}
@property
def _headers(self) -> dict[str, str]:
return {
**self._acao_headers,
"Content-Type": "application/json",
}
async def login_options(self, _: web.Request) -> web.Response:
return web.Response(status=200, headers=self._headers)
async def check_token(self, request: web.Request) -> u.User | None:
if self.ready_wait:
await self.ready_wait
self.ready_wait = None
try:
token = request.headers["Authorization"]
token = token[len("Bearer ") :]
except KeyError:
raise web.HTTPBadRequest(
text='{"error": "Missing Authorization header"}', headers=self._headers
)
except IndexError:
raise web.HTTPBadRequest(
text='{"error": "Malformed Authorization header"}',
headers=self._headers,
)
if self.shared_secret and token == self.shared_secret:
try:
user_id = request.query["user_id"]
except KeyError:
raise web.HTTPBadRequest(
text='{"error": "Missing user_id query param"}',
headers=self._headers,
)
else:
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
async def status(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
data = {
"permissions": user.permission_level,
"mxid": user.mxid,
"kakaotalk": None,
}
if user.client:
try:
info = await user.get_own_info()
except Exception:
# TODO do something?
self.log.warning(
"Exception while getting self from status endpoint", exc_info=True
)
else:
data["kakaotalk"] = info.serialize()
data["kakaotalk"]["connected"] = user.is_connected
data["kakaotalk"][
"device_displayname"
] = f"{user.state.device.manufacturer} {user.state.device.name}"
return web.json_response(data, headers=self._acao_headers)
async def login_prepare(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
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(
f"Failed to get mobile_config_sessionless to prepare login for {user.mxid}"
)
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=500)
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:
self.log.info("TODO")
"""
user = await self.check_token(request)
try:
data = await request.json()
except json.JSONDecodeError:
raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
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:
try:
encrypted_password = data["encrypted_password"]
password = None
except KeyError:
raise web.HTTPBadRequest(
text='{"error": "Missing password"}', headers=self._headers
)
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()
try:
self.log.debug(f"Logging in as {email} for {user.mxid}")
resp = await api.login(email, password=password, encrypted_password=encrypted_password)
self.log.debug(f"Got successful login response with UID {resp.uid} for {user.mxid}")
await user.on_logged_in(state)
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
except TwoFactorRequired as e:
self.log.debug(
f"Got 2-factor auth required login error with UID {e.uid} for {user.mxid}"
)
user.command_status = {
"action": "Login",
"state": state,
"api": api,
}
return web.json_response(
{
"status": "two-factor",
"error": e.data,
},
headers=self._acao_headers,
)
except OAuthException as e:
self.log.debug(f"Got OAuthException {e} for {user.mxid}")
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
"""
async def login_2fa(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
user = await self.check_token(request)
if not user.command_status or user.command_status["action"] != "Login":
raise web.HTTPBadRequest(
text='{"error": "No login in progress"}', headers=self._headers
)
try:
data = await request.json()
except json.JSONDecodeError:
raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
try:
email = data["email"]
code = data["code"]
except KeyError as e:
raise web.HTTPBadRequest(
text=json.dumps({"error": f"Missing key {e}"}), headers=self._headers
)
state: AndroidState = user.command_status["state"]
api: AndroidAPI = user.command_status["api"]
try:
self.log.debug(f"Sending 2-factor auth code for {user.mxid}")
resp = await api.login_2fa(email, code)
self.log.debug(
f"Got successful login response with UID {resp.uid} for {user.mxid}"
" after 2fa login"
)
await user.on_logged_in(state)
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
except IncorrectPassword:
self.log.debug(f"Got incorrect 2fa code error for {user.mxid}")
return web.json_response(
{
"error": "Incorrect two-factor authentication code",
"status": "incorrect-code",
},
headers=self._acao_headers,
status=401,
)
except OAuthException as e:
self.log.debug(f"Got OAuthException {e} for {user.mxid} in 2fa stage")
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
"""
async def login_approved(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
user = await self.check_token(request)
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"]
try:
self.log.debug(f"Trying to log in after approval for {user.mxid}")
resp = await api.login_approved()
self.log.debug(
f"Got successful login response with UID {resp.uid} for {user.mxid}"
" after approval login"
)
await user.on_logged_in(state)
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
except OAuthException as e:
self.log.debug(f"Got OAuthException {e} for {user.mxid} in checkpoint login stage")
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
"""
async def login_check_approved(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
user = await self.check_token(request)
if not user.command_status or user.command_status["action"] != "Login":
raise web.HTTPBadRequest(
text='{"error": "No login in progress"}', headers=self._headers
)
api: AndroidAPI = user.command_status["api"]
approved = await api.check_approved_machine()
return web.json_response({"approved": approved}, headers=self._acao_headers)
"""
async def logout(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
puppet = await pu.Puppet.get_by_ktid(user.ktid)
await user.logout()
if puppet.is_real_user:
await puppet.switch_mxid(None, None)
return web.json_response({}, headers=self._acao_headers)
async def disconnect(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
if not user.is_connected:
raise web.HTTPBadRequest(
text='{"error": "User is not connected"}', headers=self._headers
)
user.mqtt.disconnect()
await user.listen_task
return web.json_response({}, headers=self._acao_headers)
async def reconnect(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
if user.is_connected:
raise web.HTTPConflict(
text='{"error": "User is already connected"}', headers=self._headers
)
user.start_listen()
return web.json_response({}, headers=self._acao_headers)
async def refresh(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
await user.refresh()
return web.json_response({}, headers=self._acao_headers)

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a)}return e},t=new Map;export default function(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=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<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===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]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
/*! 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}

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
// From http://www-cs-students.stanford.edu/~tjw/jsbn/rng.js
// and http://www-cs-students.stanford.edu/~tjw/jsbn/prng4.js
// Copyright (c) 2005 Tom Wu
// http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE
function Arcfour(){this.i=0,this.j=0,this.S=new Array}function ARC4init(t){var r,n,i;for(r=0;r<256;++r)this.S[r]=r;for(n=0,r=0;r<256;++r)n=n+this.S[r]+t[r%t.length]&255,i=this.S[r],this.S[r]=this.S[n],this.S[n]=i;this.i=0,this.j=0}function ARC4next(){var t;return this.i=this.i+1&255,this.j=this.j+this.S[this.i]&255,t=this.S[this.i],this.S[this.i]=this.S[this.j],this.S[this.j]=t,this.S[t+this.S[this.i]&255]}function prng_newstate(){return new Arcfour}Arcfour.prototype.init=ARC4init,Arcfour.prototype.next=ARC4next;var rng_state,rng_pool,rng_pptr,rng_psize=256;function rng_seed_int(t){rng_pool[rng_pptr++]^=255&t,rng_pool[rng_pptr++]^=t>>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<z.length;++t)rng_pool[rng_pptr++]=255&z.charCodeAt(t)}for(;rng_pptr<rng_psize;)t=Math.floor(65536*Math.random()),rng_pool[rng_pptr++]=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<rng_pool.length;++rng_pptr)rng_pool[rng_pptr]=0;rng_pptr=0}return rng_state.next()}function rng_get_bytes(t){var r;for(r=0;r<t.length;++r)t[r]=rng_get_byte()}function SecureRandom(){}SecureRandom.prototype.nextBytes=rng_get_bytes;
export default SecureRandom

View File

@ -1,12 +0,0 @@
// From http://www-cs-students.stanford.edu/~tjw/jsbn/rsa.js
// Modified to use Uint8Array instead of UTF-8 strings in pkcs1pad2.
// Copyright (c) 2005 Tom Wu
// http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE
import BigInteger from "./jsbn.min.js"
import SecureRandom from "./rng.min.js"
function parseBigInt(n,t){return new BigInteger(n,t)}function linebrk(n,t){for(var e="",r=0;r+t<n.length;)e+=n.substring(r,r+t)+"\n",r+=t;return e+n.substring(r,n.length)}function byte2Hex(n){return n<16?"0"+n.toString(16):n.toString(16)}function pkcs1pad2(n,t){if(t<n.length+11)return alert("Message too long for RSA"),null;for(var e=new Array,r=n.length-1;r>=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

View File

@ -1,80 +0,0 @@
.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);
}
}

View File

@ -1,44 +0,0 @@
<!--
matrix-appservice-kakaotalk - A Matrix-KakaoTalk 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 <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>matrix-appservice-kakaotalk login</title>
<link rel="modulepreload" href="lib/jsbn.min.js"/>
<link rel="modulepreload" href="lib/rng.min.js"/>
<link rel="modulepreload" href="lib/rsa.min.js"/>
<link rel="modulepreload" href="lib/asn1hex-1.1.min.js"/>
<link rel="modulepreload" href="lib/preact-10.5.12.min.js"/>
<link rel="modulepreload" href="lib/htm-3.0.4.min.js"/>
<link rel="modulepreload" href="login/crypto.js"/>
<link rel="modulepreload" href="login/api.js"/>
<link rel="stylesheet" href="lib/normalize-8.0.1.min.css"/>
<link rel="stylesheet" href="lib/milligram-1.4.1.min.css"/>
<link rel="stylesheet" href="lib/spinner.css"/>
<link rel="stylesheet" href="login/index.css"/>
<script src="login/app.js" type="module"></script>
<script nomodule>document.body.innerText = "This login page requires modern JavaScript"</script>
</head>
<body>
<noscript>This login page requires JavaScript</noscript>
</body>
</html>

View File

@ -1,62 +0,0 @@
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk 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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -1,200 +0,0 @@
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk 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 <https://www.gnu.org/licenses/>.
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`
<label for="email">Email</label>
<input type="email" placeholder="user@example.com" id="email"
value=${this.state.email} onChange=${this.fieldChange}/>
<label for="password">Password</label>
<input type="password" placeholder="correct horse battery staple" id="password"
value=${this.state.password} onChange=${this.fieldChange}/>
`
case "two-factor":
return html`
<p>${this.state.twoFactorInfo.error_user_msg}</p>
<label for="email">Email</label>
<input type="email" placeholder="user@example.com" id="email" disabled
value=${this.state.email} onChange=${this.fieldChange}/>
<label for="twoFactorCode">Two-factor authentication code</label>
<input type="number" placeholder="123456" id="twoFactorCode"
value=${this.state.twoFactorCode} onChange=${this.fieldChange}/>
`
}
}
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`
<div class="loader">Loading...</div>
`
} 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`
<div class="error button" disabled>${this.state.error}</div>
`}
<form onSubmit=${this.submitNoDefault}>
<fieldset>
<label for="mxid">Matrix user ID</label>
<input type="text" placeholder="@user:example.com" id="mxid"
value=${this.state.mxid} disabled/>
${this.renderFields()}
<button type="submit" disabled=${this.state.submitting}>
${this.state.submitting
? "Loading..."
: this.submitButtonText()}
</button>
</fieldset>
</form>
`
}
render() {
return html`
<main>
<h1>matrix-appservice-kakaotalk login</h1>
${this.renderContent()}
</main>
`
}
}
render(html`
<${App}/>
`, document.body)

View File

@ -1,112 +0,0 @@
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk 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 <https://www.gnu.org/licenses/>.
// 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/mautrix/facebook/blob/v0.3.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

View File

@ -1,10 +0,0 @@
.error {
background-color: darkred !important;
border-color: darkred !important;
opacity: 1 !important;
}
main {
max-width: 50rem;
margin: 2rem auto 0;
}