Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encrypt access tokens #90

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 45 additions & 9 deletions src/core/wopiutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
25 changes: 21 additions & 4 deletions src/wopiserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -619,6 +621,21 @@ def cboxDownload_deprecated():
return iopDownload()


def initEncryptionKey(cls):
try:
with open(cls.config.get('security', 'jwesecretfile'), 'r') as kf:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A minor comment: for the secret we can safely reuse the already configured wopisecretfile value, which is only used by the wopiserver (not shared with Reva) to encode the JWT. In other words the same secret either encodes or encrypts depending on the config flag.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It needs to be an AES256 keywrap.

{"k":"P6mA6K6Jsaob6-ch7krZTacV9VRE7Xx58bZ8vbkV6Lw","kty":"oct"}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. But shouldn't JWE with direct encryption work as well? The payload is not that large IMHO to require the extra burden of using JWK with the key-encrypting key inside. But I'm not used to typical practices for JWE or JWK tokens so please take it with a grain of salt ;-)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me check again. I tried it with a plain "key_from_password" approach, but the byte length was always mismatching.

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
#
Expand Down