From 4c926ece2f55bb2b269b8001f92d884c694be4fa Mon Sep 17 00:00:00 2001 From: Michael Barz Date: Tue, 4 Oct 2022 23:31:05 +0200 Subject: [PATCH] Encrypt access tokens --- src/core/wopiutils.py | 54 +++++++++++++++++++++++++++++++++++-------- src/wopiserver.py | 25 ++++++++++++++++---- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/core/wopiutils.py b/src/core/wopiutils.py index 0c241de8..eb5fb434 100644 --- a/src/core/wopiutils.py +++ b/src/core/wopiutils.py @@ -20,9 +20,9 @@ from urllib.parse import quote_plus as url_quote_plus import http.client import flask -import jwt from werkzeug.utils import secure_filename import core.commoniface as common +from jwcrypto import jwk, jwe, jwt # this is the xattr key used for conflicts resolution on the remote storage LASTSAVETIMEKEY = 'iop.wopi.lastwritetime' @@ -99,10 +99,10 @@ def validateAndLogHeaders(op): srv.refreshconfig() # validate the access token try: - acctok = jwt.decode(flask.request.args['access_token'], srv.wopisecret, algorithms=['HS256']) + acctok = decodeAccessToken(flask.request.args['access_token']) if acctok['exp'] < time.time() or 'cs3org:wopiserver' not in acctok['iss']: - raise jwt.exceptions.ExpiredSignatureError - except (jwt.exceptions.DecodeError, jwt.exceptions.ExpiredSignatureError, KeyError) as e: + raise jwt.JWTExpired + except (jwt.JWTMissingKey, jwt.JWTExpired, jwe.InvalidJWEData, ValueError) as e: log.info('msg="Expired or malformed token" client="%s" requestedUrl="%s" error="%s" token="%s"' % (flask.request.remote_addr, flask.request.base_url, str(type(e)) + ': ' + str(e), flask.request.args['access_token'])) return 'Invalid access token', http.client.UNAUTHORIZED @@ -202,11 +202,22 @@ def generateAccessToken(userid, fileid, viewmode, user, folderurl, endpoint, app # does not set appname when the app is not proxied, so we optimistically assume it's Collabora and let it go) log.info('msg="Forcing read-only access to ODF file" filename="%s"' % statinfo['filepath']) viewmode = ViewMode.READ_ONLY - acctok = jwt.encode({'userid': userid, 'wopiuser': wopiuser, 'filename': statinfo['filepath'], 'username': username, - 'viewmode': viewmode.value, 'folderurl': folderurl, 'endpoint': endpoint, - 'appname': appname, 'appediturl': appediturl, 'appviewurl': appviewurl, - 'exp': exptime, 'iss': 'cs3org:wopiserver:%s' % WOPIVER}, # standard claims - srv.wopisecret, algorithm='HS256') + + acctokValues = { + 'userid': userid, + 'wopiuser': wopiuser, + 'filename': statinfo['filepath'], + 'username': username, + 'viewmode': viewmode.value, + 'folderurl': folderurl, + 'endpoint': endpoint, + 'appname': appname, + 'appediturl': appediturl, + 'appviewurl': appviewurl, + 'exp': exptime, + 'iss': 'cs3org:wopiserver:%s' % WOPIVER + } + acctok = encodeAccessToken(acctokValues) log.info('msg="Access token generated" userid="%s" wopiuser="%s" mode="%s" endpoint="%s" filename="%s" inode="%s" ' 'mtime="%s" folderurl="%s" appname="%s" expiration="%d" token="%s"' % (userid[-20:], wopiuser if wopiuser != userid else username, viewmode, endpoint, @@ -216,6 +227,31 @@ def generateAccessToken(userid, fileid, viewmode, user, folderurl, endpoint, app return statinfo['inode'], acctok, viewmode +def encodeAccessToken(acctok): + # Create a signed JWT + token = jwt.JWT(header={"alg": "HS256"}, claims=acctok) + key = jwk.JWK.from_password(srv.wopisecret) + token.make_signed_token(key) + + # if enabled, create an encrypted JWT (JWE) + if srv.config.getboolean('security', "encryptjwetoken"): + token = jwt.JWT(header={"alg": "A256KW", "enc": "A256CBC-HS512"}, claims=token.serialize()) + token.make_encrypted_token(srv.tokensecret) + + return token.serialize() + + +def decodeAccessToken(token): + # if enabled, decrypt the JWE + if srv.config.getboolean('security', "encryptjwetoken"): + token = jwt.JWT(key=srv.tokensecret, jwt=token, expected_type="JWE").claims + + # decode the signed JWT + token = jwt.JWT(key=jwk.JWK.from_password(srv.wopisecret), jwt=token, expected_type="JWS") + + return json.loads(token.claims) + + def encodeLock(lock): '''Generates the lock payload for the storage given the raw metadata''' if lock: diff --git a/src/wopiserver.py b/src/wopiserver.py index f76431c2..71c98bfb 100755 --- a/src/wopiserver.py +++ b/src/wopiserver.py @@ -23,7 +23,7 @@ import flask # Flask app server from werkzeug.exceptions import NotFound as Flask_NotFound from werkzeug.exceptions import MethodNotAllowed as Flask_MethodNotAllowed - import jwt # JSON Web Tokens support + from jwcrypto import jwk, jwe, jwt # JSON Web Tokens support from prometheus_flask_exporter import PrometheusMetrics # Prometheus support except ImportError: @@ -110,6 +110,8 @@ def init(cls): cls.codetypes = cls.config.get('general', 'codeofficetypes', fallback='.odt .ods .odp').split() with open(cls.config.get('security', 'wopisecretfile')) as s: cls.wopisecret = s.read().strip('\n') + if cls.config.getboolean('security', "encryptjwetoken"): + initEncryptionKey(cls) with open(cls.config.get('security', 'iopsecretfile')) as s: cls.iopsecret = s.read().strip('\n') cls.tokenvalidity = cls.config.getint('general', 'tokenvalidity') @@ -358,11 +360,11 @@ def iopDownload(): '''Returns the file's content for a given valid access token. Used as a download URL, so that the path and possibly the x-access-token are never explicitly visible.''' try: - acctok = jwt.decode(flask.request.args['access_token'], Wopi.wopisecret, algorithms=['HS256']) + acctok = utils.decodeAccessToken(flask.request.args['access_token']) if acctok['exp'] < time.time(): - raise jwt.exceptions.ExpiredSignatureError + raise jwt.JWTExpired return core.wopi.getFile(0, acctok) # note that here we exploit the non-dependency from fileid - except (jwt.exceptions.DecodeError, jwt.exceptions.ExpiredSignatureError, KeyError) as e: + except (jwt.JWTMissingKey, jwt.JWTExpired, jwe.InvalidJWEData, ValueError) as e: Wopi.log.info('msg="Expired or malformed token" client="%s" requestedUrl="%s" error="%s" token="%s"' % (flask.request.remote_addr, flask.request.base_url, e, flask.request.args['access_token'])) return 'Invalid access token', http.client.UNAUTHORIZED @@ -619,6 +621,21 @@ def cboxDownload_deprecated(): return iopDownload() +def initEncryptionKey(cls): + try: + with open(cls.config.get('security', 'jwesecretfile'), 'r') as kf: + key_data = kf.read() + key = jwk.JWK.from_json(key_data) + except Exception as e: + Wopi.log.info('msg="No encryption key file found: %s"' % str(e)) + try: + key = jwk.JWK.generate(kty='oct', size=256) + Wopi.log.info('msg="Generating new encryption key"') + with open(cls.config.get('security', 'jwesecretfile'), 'w') as kwf: + kwf.write(key.export()) + except Exception as ge: + Wopi.log.critical('msg="Error during encryption key generation: %s"' % str(ge)) + cls.tokensecret = key # # Start the Flask endless listening loop #