From b2822ddb95c7699a410060da8ce5f2b202d462c6 Mon Sep 17 00:00:00 2001 From: roshii Date: Tue, 29 Aug 2023 21:34:53 +0200 Subject: [PATCH] Implement wallet RPC's JWT token authority --- docs/JSON-RPC-API-using-jmwalletd.md | 16 +- docs/api/wallet-rpc.yaml | 172 ++++++++++++++++++--- jmclient/jmclient/auth.py | 100 ++++++++++++ jmclient/jmclient/wallet_rpc.py | 217 ++++++++++++++++++--------- jmclient/jmclient/websocketserver.py | 31 ++-- jmclient/test/test_auth.py | 97 ++++++++++++ jmclient/test/test_wallet_rpc.py | 179 ++++++++++++++++++++-- jmclient/test/test_websocket.py | 15 +- 8 files changed, 697 insertions(+), 130 deletions(-) create mode 100644 jmclient/jmclient/auth.py create mode 100644 jmclient/test/test_auth.py diff --git a/docs/JSON-RPC-API-using-jmwalletd.md b/docs/JSON-RPC-API-using-jmwalletd.md index f53200fca..66c2ef027 100644 --- a/docs/JSON-RPC-API-using-jmwalletd.md +++ b/docs/JSON-RPC-API-using-jmwalletd.md @@ -16,11 +16,23 @@ Documentation of the websocket functionality [below](#websocket). This HTTP server does *NOT* currently support multiple sessions; it is intended as a manager/daemon for all the Joinmarket services for a single user. Note that in particular it allows only control of *one wallet at a time*. -#### Rules about making requests +### Rules about making requests + +Note that for some methods, it's particularly important to deal with the HTTP response asynchronously, since it can take some time for wallet synchronization, service startup etc. to occur; in these cases a HTTP return code of 202 is sent. + +#### Authentication Authentication is with the [JSON Web Token](https://jwt.io/) scheme, provided using the Python package [PyJWT](https://pypi.org/project/PyJWT/). -Note that for some methods, it's particularly important to deal with the HTTP response asynchronously, since it can take some time for wallet synchronization, service startup etc. to occur; in these cases a HTTP return code of 202 is sent. +On initially creating, unlocking or recovering a wallet, a new access and refresh token will be sent in response, the former is valid for only 30 minutes and must be used for any authenticated call, the former is valid for 4 hours and can be used to request a new access token, ideally before access token expiration to avoid unauthorized response from authenticated endpoint and in any case, before refresh token expiration. + +Tokens are signed with HS256 (HMAC with SHA-256), a symmetric keyed hashing algorithm that uses one secret key. Signature keys (differentiated between access and refresh tokens) are generated from random bytes upon the following events, implying that any previously issued token is invalidated. + +- starting Joinmarket wallet deamon +- creating, unlocking or recovering a wallet if RPC API is already serving another wallet +- locking wallet + +On top of these events, refresh token signing key is also re-generated when refreshing an access token. ### API documentation diff --git a/docs/api/wallet-rpc.yaml b/docs/api/wallet-rpc.yaml index 1e13ed953..cbd137ab2 100644 --- a/docs/api/wallet-rpc.yaml +++ b/docs/api/wallet-rpc.yaml @@ -10,6 +10,30 @@ servers: - url: https://none description: This API is called locally to a jmwalletd instance, acting as server, for each wallet owner, it is not public. paths: + /token: + post: + security: + - bearerAuth: [] + summary: The token endpoint is used by the client to obtain an access token using a grant such as refresh token + operationId: token + description: > + Give a refresh token and get back both an access and refresh token. + On initially creating, unlocking or recovering a wallet, store both the refresh and access tokens, the latter is valid for only 30 minutes (must be used for any authenticated call) while the former is for 4 hours (can only be used in the refresh request parameters). Use /token endpoint on a regular basis to get new access and refresh tokens, ideally before access token expiration to avoid AuthenticationError response from authenticated endpoint and in any case, before refresh token expiration. The newly issued tokens must be used in subsequent calls since operation invalidates previously issued tokens. + responses: + '200': + $ref: '#/components/responses/RefreshToken-200-OK' + '400': + $ref: '#/components/responses/400-BadRequest' + '401': + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + description: token refresh parameters /wallet/create: post: summary: create a new wallet @@ -99,7 +123,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' /wallet/{walletname}/display: get: security: @@ -120,7 +146,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /session: @@ -135,7 +163,9 @@ paths: '200': $ref: "#/components/responses/Session-200-OK" '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /getinfo: @@ -162,11 +192,11 @@ paths: get: summary: get latest report on yield generating activity operationId: yieldgenreport - description: "Get list of coinjoins taken part in as maker (across all wallets). - Data returned as list of strings, each one in the same comma separated format as found in yigen-statement.csv. - Note that this returns all lines in the file, including the lines that are only present to represent the starting - of a bot. Those lines contain the word Connected and can be thus discarded. - The header line is also delivered and so can be ignored as per the client requirements." + description: > + Get list of coinjoins taken part in as maker (across all wallets). + Data returned as list of strings, each one in the same comma separated format as found in yigen-statement.csv. + Note that this returns all lines in the file, including the lines that are only present to represent the starting of a bot. Those lines contain the word Connected and can be thus discarded. + The header line is also delivered and so can be ignored as per the client requirements. responses: '200': $ref: "#/components/responses/YieldGenReport-200-OK" @@ -198,11 +228,15 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /wallet/{walletname}/rescanblockchain/{blockheight}: get: + security: + - bearerAuth: [] summary: Rescan the blockchain from a given blockheight operationId: rescanblockchain description: Use this operation on recovered wallets to re-sync the wallet @@ -225,7 +259,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /wallet/{walletname}/address/timelock/new/{lockdate}: @@ -256,7 +292,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /wallet/{walletname}/utxos: @@ -280,7 +318,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /wallet/{walletname}/taker/direct-send: @@ -309,7 +349,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' '409': @@ -343,6 +385,10 @@ paths: $ref: '#/components/responses/400-BadRequest' '401': $ref: '#/components/responses/401-Unauthorized' + '401': + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' '409': @@ -370,6 +416,10 @@ paths: $ref: '#/components/responses/400-BadRequest' '401': $ref: "#/components/responses/401-Unauthorized" + '401': + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /wallet/{walletname}/taker/coinjoin: @@ -399,6 +449,10 @@ paths: $ref: '#/components/responses/400-BadRequest' '401': $ref: '#/components/responses/401-Unauthorized' + '401': + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' '409': @@ -432,6 +486,10 @@ paths: $ref: '#/components/responses/400-BadRequest' '401': $ref: '#/components/responses/401-Unauthorized' + '401': + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' '409': @@ -457,7 +515,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: "#/components/responses/401-Unauthorized" + $ref: "#/components/responses/401-AuthenticationError" + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /wallet/{walletname}/taker/stop: @@ -481,6 +541,10 @@ paths: $ref: '#/components/responses/400-BadRequest' '401': $ref: "#/components/responses/401-Unauthorized" + '401': + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '404': $ref: '#/components/responses/404-NotFound' /wallet/{walletname}/configset: @@ -509,7 +573,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: '#/components/responses/401-Unauthorized' + $ref: '#/components/responses/401-AuthenticationError' + '403': + $ref: '#/components/responses/403-Forbidden' '409': $ref: '#/components/responses/409-NoConfig' /wallet/{walletname}/configget: @@ -537,7 +603,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: "#/components/responses/401-Unauthorized" + $ref: "#/components/responses/401-AuthenticationError" + '403': + $ref: '#/components/responses/403-Forbidden' '409': $ref: '#/components/responses/409-NoConfig' /wallet/{walletname}/freeze: @@ -565,6 +633,10 @@ paths: $ref: "#/components/responses/Freeze-200-OK" '400': $ref: '#/components/responses/400-BadRequest' + '401': + $ref: "#/components/responses/401-AuthenticationError" + '403': + $ref: '#/components/responses/403-Forbidden' /wallet/{walletname}/getseed: get: security: @@ -590,7 +662,9 @@ paths: '400': $ref: '#/components/responses/400-BadRequest' '401': - $ref: "#/components/responses/401-Unauthorized" + $ref: "#/components/responses/401-AuthenticationError" + '403': + $ref: '#/components/responses/403-Forbidden' components: securitySchemes: bearerAuth: @@ -662,6 +736,35 @@ components: destination: type: string example: "bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw" + TokenRequest: + type: object + required: + - grant_type + - refresh_token + properties: + grant_type: + type: string + refresh_token: + type: string + TokenResponse: + type: object + required: + - token + - token_type + - expires_in + - scope + - refresh_token + properties: + token: + type: string + token_type: + type: string + expires_in: + type: int + scope: + type: string + refresh_token: + type: string RunScheduleRequest: type: object required: @@ -919,27 +1022,31 @@ components: type: string extradata: type: string - CreateWalletResponse: type: object required: - walletname - - token - seedphrase + - token + - refresh_token properties: walletname: type: string example: wallet.jmdat + seedphrase: + type: string token: type: string format: byte - seedphrase: + refresh_token: type: string + format: byte UnlockWalletResponse: type: object required: - walletname - token + - refresh_token properties: walletname: type: string @@ -947,6 +1054,9 @@ components: token: type: string format: byte + refresh_token: + type: string + format: byte DirectSendResponse: type: object required: @@ -1087,6 +1197,8 @@ components: properties: message: type: string + error_description: + type: string responses: # Success responses @@ -1126,6 +1238,12 @@ components: application/json: schema: $ref: "#/components/schemas/ListWalletsResponse" + Token-200-OK: + description: "Access token obtained successfully" + content: + application/json: + schema: + $ref: "#/components/schemas/TokenResponse" Session-200-OK: description: "successful heartbeat response" content: @@ -1219,6 +1337,20 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorMessage' + 401-AuthenticationError: + description: Bearer token authentication error. + headers: + WWW-Authenticate: + description: Defines the HTTP authentication methods. + schema: + type: string + 403-Forbidden: + description: Bearer token authorization error. + headers: + WWW-Authenticate: + description: Defines the HTTP authentication methods. + schema: + type: string 409-AlreadyExists: description: Unable to complete request because object already exists. content: diff --git a/jmclient/jmclient/auth.py b/jmclient/jmclient/auth.py new file mode 100644 index 000000000..84a259760 --- /dev/null +++ b/jmclient/jmclient/auth.py @@ -0,0 +1,100 @@ +import datetime +import os + +import jwt + +from jmbase.support import bintohex + + +class InvalidScopeError(Exception): + pass + + +class ExpiredSignatureError(jwt.exceptions.ExpiredSignatureError): + pass + + +def get_random_key(size: int = 16) -> str: + """Create a random key has an hexadecimal string.""" + return bintohex(os.urandom(size)) + + +class JMTokenAuthority: + """Manage authorization tokens.""" + + SESSION_VALIDITY = { + "access": datetime.timedelta(minutes=30), + "refresh": datetime.timedelta(hours=4), + } + SIGNATURE_ALGORITHM = "HS256" + + def __init__(self, *wallet_names: str): + self.signature_key = { + "access": get_random_key(), + "refresh": get_random_key(), + } + self._scope = {"walletrpc"} + for wallet_name in wallet_names: + self.add_to_scope(wallet_name) + + def verify(self, token: str, *, token_type: str = "access"): + """Verify JWT token. + + Token must have a valid signature and its scope must contain both scopes in + arguments and wallet_name property. + """ + try: + claims = jwt.decode( + token, + self.signature_key[token_type], + algorithms=self.SIGNATURE_ALGORITHM, + leeway=10, + ) + except jwt.exceptions.ExpiredSignatureError: + raise ExpiredSignatureError + + token_claims = set(claims.get("scope", []).split()) + if not self._scope <= token_claims: + raise InvalidScopeError + + def add_to_scope(self, *args: str): + for arg in args: + self._scope.add(arg) + + def discard_from_scope(self, *args: str): + for arg in args: + self._scope.discard(arg) + + @property + def scope(self): + return " ".join(self._scope) + + def _issue(self, token_type: str) -> str: + return jwt.encode( + { + "exp": datetime.datetime.utcnow() + self.SESSION_VALIDITY[token_type], + "scope": self.scope, + }, + self.signature_key[token_type], + algorithm=self.SIGNATURE_ALGORITHM, + ) + + def issue(self) -> dict: + """Issue a new access and refresh token. + Previously issued refresh token is invalidated. + """ + self.signature_key["refresh"] = get_random_key() + return { + "token": self._issue("access"), + "token_type": "bearer", + "expires_in": int(self.SESSION_VALIDITY["access"].total_seconds()), + "scope": self.scope, + "refresh_token": self._issue("refresh"), + } + + def reset(self): + """Invalidate all previously issued tokens by creating new signature keys.""" + self.signature_key = { + "access": get_random_key(), + "refresh": get_random_key(), + } diff --git a/jmclient/jmclient/wallet_rpc.py b/jmclient/jmclient/wallet_rpc.py index a6c7efda2..09d41c399 100644 --- a/jmclient/jmclient/wallet_rpc.py +++ b/jmclient/jmclient/wallet_rpc.py @@ -1,5 +1,3 @@ -from jmbitcoin import * -import datetime import os import json from io import BytesIO @@ -9,7 +7,6 @@ from twisted.application.service import Service from autobahn.twisted.websocket import listenWS from klein import Klein -import jwt import pprint from jmbitcoin import human_readable_transaction @@ -26,7 +23,7 @@ get_schedule, get_tumbler_parser, schedule_to_text, \ tumbler_filter_orders_callback, tumbler_taker_finished_update, \ validate_address, FidelityBondMixin, BaseWallet, WalletError, \ - ScheduleGenerationErrorNoFunds, BIP39WalletMixin + ScheduleGenerationErrorNoFunds, BIP39WalletMixin, auth from jmbase.support import get_log, utxostr_to_utxo, JM_CORE_VERSION jlog = get_log() @@ -43,7 +40,16 @@ def print_req(request): print(request.content) print(list(request.requestHeaders.getAllRawHeaders())) -class NotAuthorized(Exception): +class AuthorizationError(Exception): + pass + +class InvalidCredentials(AuthorizationError): + pass + +class InvalidToken(AuthorizationError): + pass + +class InsufficientScope(AuthorizationError): pass class NoWalletFound(Exception): @@ -124,7 +130,7 @@ def make_jmwalletd_response(request, status=200, **kwargs): """ request.setHeader('Content-Type', 'application/json') request.setHeader('Access-Control-Allow-Origin', '*') - request.setHeader("Cache-Control", "no-cache, must-revalidate") + request.setHeader("Cache-Control", "no-cache, no-store, must-revalidate") request.setHeader("Pragma", "no-cache") request.setHeader("Expires", "Sat, 26 Jul 1997 05:00:00 GMT") request.setResponseCode(status) @@ -134,7 +140,7 @@ def make_jmwalletd_response(request, status=200, **kwargs): class JMWalletDaemon(Service): """ This class functions as an HTTP/TLS server, - with acccess control, allowing a single client(user) + with access control, allowing a single client(user) to control functioning of encapsulated Joinmarket services. """ @@ -146,7 +152,8 @@ def __init__(self, port, wss_port, tls=True): websocket connections for clients to subscribe to updates. """ # cookie tracks single user's state. - self.cookie = None + self.token = auth.JMTokenAuthority() + self.active_session = False self.port = port self.wss_port = wss_port self.tls = tls @@ -225,7 +232,7 @@ def startService(self): # wallet service, since the client must actively request # that with the appropriate credential (password). # initialise the web socket service for subscriptions - self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url) + self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url, self.token) self.wss_factory.protocol = JmwalletdWebSocketServerProtocol if self.tls: cf = get_ssl_context(os.path.join(jm_single().datadir, "ssl")) @@ -251,11 +258,9 @@ def stopSubServices(self): - shuts down any other running sub-services, such as yieldgenerator. - shuts down (aborts) any taker-side coinjoining happening. """ - # Currently valid authorization tokens must be removed - # from the daemon: - self.cookie = None - if self.wss_factory: - self.wss_factory.valid_token = None + self.token.reset() + self.active_session = False + self.wss_factory.active_session = False self.wallet_name = None # if the wallet-daemon is shut down, all services # it encapsulates must also be shut down. @@ -274,6 +279,13 @@ def stopSubServices(self): self.taker.aborted = True self.taker_finished(False) + def auth_err(self, request, error, description=None): + request.setHeader("WWW-Authenticate", "Bearer") + request.setHeader("WWW-Authenticate", f'error="{error}"') + if description is not None: + request.setHeader("WWW-Authenticate", f'error_description="{description}"') + return + def err(self, request, message): """ Return errors in a standard format. """ @@ -285,11 +297,26 @@ def not_allowed(self, request, failure): request.setResponseCode(400) return self.err(request, "Action not allowed") - @app.handle_errors(NotAuthorized) - def not_authorized(self, request, failure): + @app.handle_errors(InvalidCredentials) + def invalid_credentials(self, request, failure): request.setResponseCode(401) return self.err(request, "Invalid credentials.") + @app.handle_errors(InvalidToken) + def invalid_token(self, request, failure): + request.setResponseCode(401) + return self.auth_err(request, "invalid_token", str(failure)) + + @app.handle_errors(InsufficientScope) + def insufficient_scope(self, request, failure): + request.setResponseCode(403) + return self.auth_err( + request, + "insufficient_scope", + "The request requires higher privileges (scopes) than provided by " + "the scopes granted to the client and represented by the access token.", + ) + @app.handle_errors(NoWalletFound) def no_wallet_found(self, request, failure): request.setResponseCode(404) @@ -361,43 +388,29 @@ def yieldgenerator_report_unavailable(self, request, failure): request.setResponseCode(404) return self.err(request, "Yield generator report not available.") - def check_cookie(self, request): - #part after bearer is what we need + def check_cookie(self, request, *, verify_exp: bool = True): + # Token itself is stated after `Bearer ` prefix, it must be removed + access_token = request.getHeader("Authorization")[7:] try: - auth_header=((request.getHeader('Authorization'))) - request_cookie = None - if auth_header is not None: - request_cookie=auth_header[7:] - except Exception: - # deliberately catching anything - raise NotAuthorized() - if request_cookie==None or self.cookie != request_cookie: - jlog.warn("Invalid cookie: " + str( - request_cookie) + ", request rejected.") - raise NotAuthorized() + self.token.verify(access_token) + except auth.InvalidScopeError: + raise InsufficientScope() + except auth.ExpiredSignatureError: + if verify_exp: + raise InvalidToken("The access token provided is expired.") + else: + pass + except Exception as e: + jlog.debug(e) + raise InvalidToken( + "The access token provided is revoked, malformed, or invalid for other reasons." + ) def check_cookie_if_present(self, request): auth_header = request.getHeader('Authorization') if auth_header is not None: self.check_cookie(request) - def set_token(self, wallet_name): - """ This function creates a new JWT token and sets it as our - 'cookie' for API and WS. Note this always creates a new fresh token, - there is no option to manually set it, intentionally. - """ - # any random secret is OK, as long as it is not deducible/predictable: - secret_key = bintohex(os.urandom(16)) - encoded_token = jwt.encode({"wallet": wallet_name, - "exp" :datetime.datetime.utcnow( - )+datetime.timedelta(minutes=30)}, - secret_key) - self.cookie = encoded_token.strip() - # We want to make sure that any websocket clients use the correct - # token. The wss_factory should have been created on JMWalletDaemon - # startup, so any failure to exist here is a logic error: - self.wss_factory.valid_token = self.cookie - def get_POST_body(self, request, required_keys, optional_keys=None): """ given a request object, retrieve values corresponding to keys in a dict, assuming they were encoded using JSON. @@ -468,27 +481,31 @@ def initialize_wallet_service(self, request, wallet, wallet_name, **kwargs): def dummy_restart_callback(msg): jlog.warn("Ignoring rescan request from backend wallet service: " + msg) self.services["wallet"].add_restart_callback(dummy_restart_callback) + self.active_session = True + self.wss_factory.active_session = True self.wallet_name = wallet_name + # Add wallet_name to token scope + self.token.add_to_scope(wallet_name) self.services["wallet"].register_callbacks( [self.wss_factory.sendTxNotification], None) self.services["wallet"].startService() # now that the WalletService instance is active and ready to # respond to requests, we return the status to the client: - # First, prepare authentication for the calling client: - self.set_token(wallet_name) # return type is different for a newly created OR recovered # wallet, in this case we use the 'seedphrase' kwarg as trigger: - if('seedphrase' in kwargs): - return make_jmwalletd_response(request, - status=201, - walletname=self.wallet_name, - token=self.cookie, - seedphrase=kwargs.get('seedphrase')) + if "seedphrase" in kwargs: + return make_jmwalletd_response( + request, + status=201, + walletname=self.wallet_name, + seedphrase=kwargs.get("seedphrase"), + **self.token.issue(), + ) else: - return make_jmwalletd_response(request, - walletname=self.wallet_name, - token=self.cookie) + return make_jmwalletd_response( + request, walletname=self.wallet_name, **self.token.issue() + ) def taker_finished(self, res, fromtx=False, waittime=0.0, txdetails=None): if not self.tumbler_options: @@ -582,6 +599,57 @@ def preflight(self, request): request.setHeader("Access-Control-Allow-Methods", "POST") with app.subroute(api_version_string) as app: + @app.route('/token', methods=['POST']) + def refresh(self, request): + self.check_cookie(request, verify_exp=False) + + def _mkerr(err, description=""): + return make_jmwalletd_response( + request, status=400, message=err, error_description=description + ) + + try: + assert isinstance(request.content, BytesIO) + grant_type = self.get_POST_body(request, ["grant_type",])["grant_type"] + if grant_type not in {"refresh_token"}: + return _mkerr( + "unsupported_grant_type", + "The authorization grant type is not supported by the authorization server.", + ) + + token = self.get_POST_body(request, [grant_type])[grant_type] + except: + return _mkerr( + "invalid_request", + "The request is missing a required parameter, " + "includes an unsupported parameter value (other than grant type), " + "repeats a parameter, includes multiple credentials, " + "or is otherwise malformed.", + ) + try: + self.token.verify(token, token_type=grant_type.split("_")[0]) + return make_jmwalletd_response( + request, walletname=self.wallet_name, **self.token.issue() + ) + + except auth.ExpiredSignatureError: + return _mkerr( + "invalid_grant", + f"The provided {grant_type} is expired.", + ) + except auth.InvalidScopeError: + return _mkerr( + "invalid_scope", + "The requested scope is invalid, unknown, malformed, " + "or exceeds the scope granted by the resource owner.", + ) + except auth.ExpiredSignatureError: + return _mkerr( + "invalid_grant", + f"The provided {grant_type} is invalid, revoked, " + "or was issued to another client.", + ) + @app.route('/wallet//display', methods=['GET']) def displaywallet(self, request, walletname): print_req(request) @@ -638,9 +706,6 @@ def session(self, request): #this lets caller know if cookie is invalid or outdated self.check_cookie_if_present(request) - #if no wallet loaded then clear frontend session info - #when no wallet status is false - session = not self.cookie==None maker_running = self.coinjoin_state == CJ_MAKER_RUNNING coinjoin_in_process = self.coinjoin_state == CJ_TAKER_RUNNING @@ -676,14 +741,17 @@ def session(self, request): else: wallet_name = "None" - return make_jmwalletd_response(request,session=session, - maker_running=maker_running, - coinjoin_in_process=coinjoin_in_process, - schedule=schedule, - wallet_name=wallet_name, - offer_list=offer_list, - nickname=nickname, - rescanning=rescanning) + return make_jmwalletd_response( + request, + session=self.active_session, + maker_running=maker_running, + coinjoin_in_process=coinjoin_in_process, + schedule=schedule, + wallet_name=wallet_name, + offer_list=offer_list, + nickname=nickname, + rescanning=rescanning, + ) @app.route('/wallet//taker/direct-send', methods=['POST']) def directsend(self, request, walletname): @@ -980,7 +1048,7 @@ def unlockwallet(self, request, walletname): read_only=True) except StoragePasswordError: # actually effects authentication - raise NotAuthorized() + raise InvalidCredentials() except StorageError: # wallet is not openable, this should not happen raise NoWalletFound() @@ -989,10 +1057,11 @@ def unlockwallet(self, request, walletname): # this also shouldn't happen so raise: raise NoWalletFound() # no exceptions raised means we just return token: - self.set_token(self.wallet_name) - return make_jmwalletd_response(request, - walletname=self.wallet_name, - token=self.cookie) + return make_jmwalletd_response( + request, + walletname=self.wallet_name, + **self.token.issue(), + ) # This is a different wallet than the one currently open; # try to open it, then initialize the service(s): @@ -1003,7 +1072,7 @@ def unlockwallet(self, request, walletname): ask_for_password=False, gap_limit = jm_single().config.getint("POLICY", "gaplimit")) except StoragePasswordError: - raise NotAuthorized() + raise InvalidCredentials() except RetryableStorageError: # .lock file exists raise LockExists() diff --git a/jmclient/jmclient/websocketserver.py b/jmclient/jmclient/websocketserver.py index 1c126cbad..f160487bb 100644 --- a/jmclient/jmclient/websocketserver.py +++ b/jmclient/jmclient/websocketserver.py @@ -1,6 +1,8 @@ import json from autobahn.twisted.websocket import WebSocketServerFactory, \ WebSocketServerProtocol + +from .auth import JMTokenAuthority from jmbitcoin import human_readable_transaction from jmbase import get_log @@ -8,19 +10,18 @@ class JmwalletdWebSocketServerProtocol(WebSocketServerProtocol): def onOpen(self): - self.token = None + self.active_session = False self.factory.register(self) def sendNotification(self, info): """ Passes on an object (json encoded) to the client, if currently authenticated. """ - if not self.token: - # gating by token means even if this client - # is erroneously in a broadcast list, it won't get - # any data if it hasn't authenticated. + if not self.active_session: + # not sending any data if the session is + # not active, i.e. client hasn't authenticated. jlog.warn("Websocket not sending notification, " - "the connection is not authenticated.") + "the session is not active.") return self.sendMessage(json.dumps(info).encode()) @@ -37,22 +38,22 @@ def onMessage(self, payload, isBinary): other message will drop the connection. """ if not isBinary: - self.token = payload.decode('utf8') + token = payload.decode('utf8') # check that the token set for this protocol - # instance is the same as the one that the - # JMWalletDaemon instance deems is valid. - if not self.factory.check_token(self.token): + # instance is valid. + try: + self.factory.token.verify(token) + self.active_session = True + except Exception as e: + jlog.debug(e) self.dropConnection() class JmwalletdWebSocketServerFactory(WebSocketServerFactory): - def __init__(self, url): + def __init__(self, url, token_authority = JMTokenAuthority()): WebSocketServerFactory.__init__(self, url) - self.valid_token = None + self.token = token_authority self.clients = [] - def check_token(self, token): - return self.valid_token == token - def register(self, client): if client not in self.clients: self.clients.append(client) diff --git a/jmclient/test/test_auth.py b/jmclient/test/test_auth.py new file mode 100644 index 000000000..b1f65e466 --- /dev/null +++ b/jmclient/test/test_auth.py @@ -0,0 +1,97 @@ +"""test auth module.""" + +import copy +import datetime + +import jwt +import pytest + +from jmclient.auth import ExpiredSignatureError, InvalidScopeError, JMTokenAuthority + + +class TestJMTokenAuthority: + wallet_name = "dummywallet" + token_auth = JMTokenAuthority(wallet_name) + + access_sig = copy.copy(token_auth.signature_key["access"]) + refresh_sig = copy.copy(token_auth.signature_key["refresh"]) + + validity = datetime.timedelta(hours=1) + scope = f"walletrpc {wallet_name}" + + @pytest.mark.parametrize( + "sig, token_type", [(access_sig, "access"), (refresh_sig, "refresh")] + ) + def test_verify_valid(self, sig, token_type): + token = jwt.encode( + {"exp": datetime.datetime.utcnow() + self.validity, "scope": self.scope}, + sig, + algorithm=self.token_auth.SIGNATURE_ALGORITHM, + ) + + try: + self.token_auth.verify(token, token_type=token_type) + except Exception as e: + print(e) + pytest.fail("Token verification failed, token is valid.") + + def test_verify_expired(self): + token = jwt.encode( + {"exp": datetime.datetime.utcnow() - self.validity, "scope": self.scope}, + self.access_sig, + algorithm=self.token_auth.SIGNATURE_ALGORITHM, + ) + + with pytest.raises(ExpiredSignatureError): + self.token_auth.verify(token) + + def test_verify_non_scoped(self): + token = jwt.encode( + {"exp": datetime.datetime.utcnow() + self.validity, "scope": "wrong"}, + self.access_sig, + algorithm=self.token_auth.SIGNATURE_ALGORITHM, + ) + + with pytest.raises(InvalidScopeError): + self.token_auth.verify(token) + + def test_issue(self): + def scope_equals(scope): + return set(scope.split(" ")) == set(self.scope.split(" ")) + + token_response = self.token_auth.issue() + + assert token_response.pop("expires_in") == int( + self.token_auth.SESSION_VALIDITY["access"].total_seconds() + ) + assert token_response.pop("token_type") == "bearer" + assert scope_equals(token_response.pop("scope")) + + try: + for k, v in token_response.items(): + claims = jwt.decode( + v, + self.token_auth.signature_key["refresh"] + if k == "refresh_token" + else self.token_auth.signature_key["access"], + algorithms=self.token_auth.SIGNATURE_ALGORITHM, + ) + assert scope_equals(claims.get("scope")) + assert self.token_auth.signature_key["refresh"] != self.refresh_sig + except jwt.exceptions.InvalidTokenError: + pytest.fail("An invalid token was issued.") + + def test_scope_operation(self): + assert "walletrpc" in self.token_auth._scope + assert self.wallet_name in self.token_auth._scope + + scope = copy.copy(self.token_auth._scope) + s = "new_wallet" + + self.token_auth.add_to_scope(s) + assert scope < self.token_auth._scope + assert s in self.token_auth._scope + + self.token_auth.discard_from_scope(s, "walletrpc") + assert scope > self.token_auth._scope + assert s not in self.token_auth._scope diff --git a/jmclient/test/test_wallet_rpc.py b/jmclient/test/test_wallet_rpc.py index ee21f80e9..d0904874e 100644 --- a/jmclient/test/test_wallet_rpc.py +++ b/jmclient/test/test_wallet_rpc.py @@ -1,11 +1,14 @@ -import os, json +import base64 +import datetime +import functools +import json +import os +import jwt import pytest from twisted.internet import reactor, defer, task - from twisted.web.client import readBody, Headers from twisted.trial import unittest - from autobahn.twisted.websocket import WebSocketClientFactory, \ connectWS @@ -27,7 +30,7 @@ from test_coinjoin import make_wallets_to_list, sync_wallets from test_websocket import (ClientTProtocol, test_tx_hex_1, - test_tx_hex_txid, encoded_token) + test_tx_hex_txid, test_token_authority) pytestmark = pytest.mark.usefixtures("setup_regtest_bitcoind") @@ -38,10 +41,14 @@ jlog = get_log() class JMWalletDaemonT(JMWalletDaemon): - def check_cookie(self, request): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.token = test_token_authority + + def check_cookie(self, request, *args, **kwargs): if self.auth_disabled: return True - return super().check_cookie(request) + return super().check_cookie(request, *args, **kwargs) class WalletRPCTestBase(object): """ Base class for set up of tests of the @@ -87,6 +94,7 @@ def setUp(self): # (and don't use wallet files yet), we won't have set a wallet name, # so we set it here: self.daemon.wallet_name = self.get_wallet_file_name(1) + self.daemon.token.wallet_name = self.daemon.wallet_name r, s = self.daemon.startService() self.listener_rpc = r self.listener_ws = s @@ -127,7 +135,7 @@ def get_wallet_file_name(self, i, fullpath=False): @defer.inlineCallbacks def do_request(self, agent, method, addr, body, handler, token=None): if token: - headers = Headers({"Authorization": ["Bearer " + self.jwt_token]}) + headers = Headers({"Authorization": ["Bearer " + token]}) else: headers = None response = yield agent.request(method, addr, headers, bodyProducer=body) @@ -201,9 +209,9 @@ class TrialTestWRPC_WS(WalletRPCTestBase, unittest.TestCase): def test_notif(self): # simulate the daemon already having created - # a valid token (which it usually does when + # an active session (which it usually does when # starting the WalletService: - self.daemon.wss_factory.valid_token = encoded_token + self.daemon.wss_factory.protocol.active_session = True # once the websocket connection is established, and auth # is sent, our custom clientfactory will fire the tx # notification via the callback passed as argument here; @@ -744,6 +752,159 @@ def process_get_seed_response(self, response, code): json_body = json.loads(response.decode('utf-8')) assert json_body["seedphrase"] + +class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase): + def get_token(self, grant_type: str, status: str = "valid"): + now, delta = datetime.datetime.utcnow(), datetime.timedelta(hours=1) + exp = now - delta if status == "expired" else now + delta + + scope = f"walletrpc {self.daemon.wallet_name}" + if status == "invalid_scope": + scope = "walletrpc another_wallet" + + alg = test_token_authority.SIGNATURE_ALGORITHM + if status == "invalid_alg": + alg = ({"HS256", "HS384", "HS512"} - {alg}).pop() + + t = jwt.encode( + {"exp": exp, "scope": scope}, + test_token_authority.signature_key[grant_type], + algorithm=test_token_authority.SIGNATURE_ALGORITHM, + ) + + if status == "invalid_sig": + # Get token string + token_parts = t.split(".") + sig = token_parts[-1] + + # Pad as needed + if len(sig) % 4 != 0: + sig += "=" * (len(sig) % 4) + + # Flip fist byte, unpad + sig_bytes = base64.urlsafe_b64decode(sig) + flipped_bytes = bytes([sig_bytes[0] ^ 1]) + sig_bytes[1:] + flipped_sig = base64.urlsafe_b64encode(flipped_bytes).replace(b"=", b"") + + # Reconstruct JWT with invalid sig + token_parts[-1] = str(flipped_sig) + t = ".".join(token_parts) + + return t + + def authorized_response_handler(self, response, code): + assert code == 200 + + def forbidden_response_handler(self, response, code): + assert code == 403 + assert "insufficient_scope" in response.headers.get("WWW-Authenticate") + + def unauthorized_response_handler(self, response, code): + assert code == 401 + assert "Bearer" in response.headers.get("WWW-Authenticate") + + def expired_access_token_response_handler(self, response, code): + self.unauthorized_response_handler(response, code) + assert "expired" in response.headers.get("WWW-Authenticate") + + async def test_jwt_authentication(self): + """Test JWT authentication and authorization""" + + agent = get_nontor_agent() + addr = (self.get_route_root() + "/session").encode() + + for access_token_status, responde_handler in [ + ("valid", "authorized"), + ("expired", "expired"), + ("invalid_scope", "forbidden"), + ("invalid_sig", "unauthorized"), + ("invalid_alg", "unauthorized"), + ]: + handler = { + "authorized": self.authorized_response_handler, + "expired": self.expired_access_token_response_handler, + "forbidden": self.forbidden_response_handler, + "unauthorized": self.unauthorized_response_handler, + }[responde_handler] + token = self.get_token("access", access_token_status) + + await self.do_request(agent, b"GET", addr, None, handler, token) + + def successful_refresh_response_handler(self, response, code): + self.authorized_response_handler(response, code) + json_body = json.loads(response.decode("utf-8")) + assert {"token", "refresh_token", "expires_in", "token_type", "scope"} <= set( + json_body.keys() + ) + + def failed_refresh_response_handler( + self, response, code, *, message=None, error_description=None + ): + assert code == 400 + json_body = json.loads(response.decode("utf-8")) + if message is not None: + assert json_body.get("message") == message + if error_description is not None: + assert error_description in json_body.get("error_description") + + async def do_refresh_request(self, body, handler, token): + agent = get_nontor_agent() + addr = (self.get_route_root() + "/token").encode() + body = BytesProducer(json.dumps(body).encode()) + await self.do_request(agent, b"POST", addr, body, handler, token) + + def test_refresh_token_request(self): + """Test token endpoint with valid refresh token""" + for access_token_status, request_status, error in [ + ("valid", "valid", None), + ("expired", "valid", None), + ("valid", "invalid_request", "invalid_request"), + ("valid", "invalid_grant", "unsupported_grant_type"), + ]: + if error is None: + handler = self.successful_refresh_response_handler + else: + handler = functools.partialmethod( + self.failed_refresh_response_handler, message=error + ) + + body = { + "grant_type": "refresh_token", + "refresh_token": self.get_token("refresh"), + } + if request_status == "invalid_request": + body["refresh"] = body.pop("refresh_token") + if request_status == "unsupported_grant_type": + body["grant_type"] = "joinmarket" + + self.do_refresh_request( + body, handler, self.get_token("access", access_token_status) + ) + + async def test_refresh_token(self): + """Test refresh token endpoint""" + for refresh_token_status, error in [ + ("expired", "expired"), + ("invalid_scope", "invalid_scope"), + ("invalid_sig", "invalid_grant"), + ]: + if error == "expired": + handler = functools.partialmethod( + self.failed_refresh_response_handler, error_description=error + ) + else: + handler = functools.partialmethod( + self.failed_refresh_response_handler, message=error + ) + + body = { + "grant_type": "refresh_token", + "refresh_token": self.get_token("refresh", refresh_token_status), + } + + self.do_refresh_request(body, handler, self.get_token("access")) + + """ Sample listutxos response for reference: diff --git a/jmclient/test/test_websocket.py b/jmclient/test/test_websocket.py index cf89bcf56..38ba9d87c 100644 --- a/jmclient/test/test_websocket.py +++ b/jmclient/test/test_websocket.py @@ -1,17 +1,16 @@ import os import json -import datetime from twisted.internet import reactor, task from twisted.trial import unittest from autobahn.twisted.websocket import WebSocketClientFactory, \ WebSocketClientProtocol, connectWS, listenWS -import jwt from jmbase import get_log, hextobin from jmbase.support import get_free_tcp_ports from jmclient import (JmwalletdWebSocketServerFactory, JmwalletdWebSocketServerProtocol) +from jmclient.auth import JMTokenAuthority from jmbitcoin import CTransaction testdir = os.path.dirname(os.path.realpath(__file__)) @@ -21,11 +20,8 @@ test_tx_hex_1 = "02000000000102578770b2732aed421ffe62d54fd695cf281ca336e4f686d2adbb2e8c3bedb2570000000000ffffffff4719a259786b4237f92460629181edcc3424419592529103143090f07d85ec330100000000ffffffff0324fd9b0100000000160014d38fa4a6ac8db7495e5e2b5d219dccd412dd9bae24fd9b0100000000160014564aead56de8f4d445fc5b74a61793b5c8a819667af6c208000000001600146ec55c2e1d1a7a868b5ec91822bf40bba842bac502473044022078f8106a5645cc4afeef36d4addec391a5b058cc51053b42c89fcedf92f4db1002200cdf1b66a922863fba8dc1b1b1a0dce043d952fa14dcbe86c427fda25e930a53012102f1f750bfb73dbe4c7faec2c9c301ad0e02176cd47bcc909ff0a117e95b2aad7b02483045022100b9a6c2295a1b0f7605381d416f6ed8da763bd7c20f2402dd36b62dd9dd07375002207d40eaff4fc6ee219a7498abfab6bdc54b7ce006ac4b978b64bff960fbf5f31e012103c2a7d6e44acdbd503c578ec7d1741a44864780be0186e555e853eee86e06f11f00000000" test_tx_hex_txid = "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc" -# example (valid) JWT token for test: -encoded_token = jwt.encode({"wallet": "dummywallet", - "exp" :datetime.datetime.utcnow( - )+datetime.timedelta(minutes=30)}, "secret") -encoded_token = encoded_token.strip() +# Shared JWT token authority for test: +test_token_authority = JMTokenAuthority("dummywallet") class ClientTProtocol(WebSocketClientProtocol): """ @@ -37,7 +33,7 @@ def sendAuth(self): """ Our server will not broadcast to us unless we authenticate. """ - self.sendMessage(encoded_token.encode('utf8')) + self.sendMessage(test_token_authority.issue()["token"].encode('utf8')) def onOpen(self): # auth on startup @@ -69,9 +65,8 @@ def setUp(self): free_ports = get_free_tcp_ports(1) self.wss_port = free_ports[0] self.wss_url = "ws://127.0.0.1:" + str(self.wss_port) - self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url) + self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url, test_token_authority) self.wss_factory.protocol = JmwalletdWebSocketServerProtocol - self.wss_factory.valid_token = encoded_token self.listeningport = listenWS(self.wss_factory, contextFactory=None) self.test_tx = CTransaction.deserialize(hextobin(test_tx_hex_1))