diff --git a/README.md b/README.md index b80ea032..9b629489 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ SKALE Node CLI, part of the SKALE suite of validator tools, is the command line 4. [sChain commands (Standard)](#schain-commands-standard) 5. [Health commands (Standard)](#health-commands-standard) 6. [SSL commands (Standard)](#ssl-commands-standard) - 7. [Logs commands (Standard)](#logs-commands-standard) + 7. [SGX commands (Standard)](#sgx-commands-standard) + 8. [Logs commands (Standard)](#logs-commands-standard) 3. [Passive Node Usage (`skale` - Passive Build)](#passive-node-usage-skale---passive-build) 1. [Top level commands (Passive)](#top-level-commands-passive) 2. [Passive node commands](#passive-node-commands) @@ -32,8 +33,9 @@ SKALE Node CLI, part of the SKALE suite of validator tools, is the command line 5. [Fair Wallet commands](#fair-wallet-commands) 6. [Fair Logs commands](#fair-logs-commands) 7. [Fair SSL commands](#fair-ssl-commands) - 8. [Fair Staking commands](#fair-staking-commands) - 9. [Passive Fair Node commands](#passive-fair-node-commands) + 8. [Fair SGX commands](#fair-sgx-commands) + 9. [Fair Staking commands](#fair-staking-commands) + 10. [Passive Fair Node commands](#passive-fair-node-commands) 5. [Exit codes](#exit-codes) 6. [Development](#development) @@ -499,6 +501,48 @@ Options: * `--port/-p` - Port to start healthcheck server (default: `4536`). * `--no-client` - Skip client connection (only make sure server started without errors). +### SGX commands (Standard) + +> Prefix: `skale sgx` + +Manage the client certificate that node services use to authenticate to the SGX wallet. +The files live in `~/.skale/node_data/sgx_certs` and are read by the SKALE containers. +These commands work directly with those files and the SGX server; they do not go through +the node API. + +#### SGX certificate status + +Show the certificate files, the certificate details and its expiry. + +```shell +skale sgx status [--json] [--check] +``` + +Options: + +* `--json` - Show data in JSON format. +* `--check` - Also verify that the SGX server accepts the certificate. + +#### Renew SGX certificate + +Issue a new client certificate from the SGX server and install it. The current +certificate stays in place until the new one is signed and verified against the server. +The previous files are copied to `~/.skale/node_data/sgx_certs_backup/`. +If the SGX server requires manual approval of signing requests, the command prints the +request hash and waits until it is approved. Node services pick up the new certificate +on their next SGX request; no restart is needed. `skale health sgx` confirms afterwards +that node services reach the SGX server. + +```shell +skale sgx renew [--yes] [--timeout ] [--skip-verify] +``` + +Options: + +* `--yes` - Do not ask for confirmation. +* `--timeout` - Seconds to wait for the SGX server to sign the request (default: `600`). +* `--skip-verify` - Install the certificate without testing it against the SGX server first. + ### Logs commands (Standard) > Prefix: `skale logs` @@ -1119,6 +1163,25 @@ Options: * `--no-client` - Skip client connection for openssl check. * `--no-wss` - Skip WSS server starting for skaled check. +### Fair SGX commands + +> Prefix: `fair sgx` + +Manage the client certificate that node services use to authenticate to the SGX wallet. +See [SGX commands (Standard)](#sgx-commands-standard) for details; the behaviour is the same. + +#### Fair SGX Status + +```shell +fair sgx status [--json] [--check] +``` + +#### Fair SGX Renew + +```shell +fair sgx renew [--yes] [--timeout ] [--skip-verify] +``` + ### Fair Staking commands > Prefix: `fair staking` diff --git a/node_cli/cli/sgx.py b/node_cli/cli/sgx.py new file mode 100644 index 00000000..e1635617 --- /dev/null +++ b/node_cli/cli/sgx.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# +# This file is part of node-cli +# +# Copyright (C) 2026 SKALE Labs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json + +import click +from terminaltables import SingleTable + +from node_cli.configs.sgx import SGX_SIGN_TIMEOUT +from node_cli.core.sgx import ( + SgxCertificateError, + check_certificate, + get_certificate_status, + get_server_options, + renew_certificate, +) +from node_cli.utils.decorators import check_inited, check_user +from node_cli.utils.exit_codes import CLIExitCodes +from node_cli.utils.helper import abort_if_false, error_exit +from node_cli.utils.settings import get_sgx_url +from node_cli.utils.texts import safe_load_texts + +G_TEXTS = safe_load_texts() +TEXTS = G_TEXTS['sgx'] + + +@click.group() +def sgx_cli(): + pass + + +@sgx_cli.group('sgx', help=TEXTS['help']) +def sgx(): + pass + + +@sgx.command('options', help=TEXTS['options']['help']) +@click.option('--json', 'json_format', is_flag=True, help=G_TEXTS['common']['json']) +@check_inited +@check_user +def options(json_format: bool) -> None: + _configured_sgx_url() + status, payload = get_server_options() + if status != 'ok': + error_exit(payload, exit_code=CLIExitCodes.BAD_API_RESPONSE) + if json_format: + print(json.dumps(payload)) + else: + rows = [['SGX option', 'Value']] + for group, values in payload.items(): + entries = ( + [(f'{group}.{key}', value) for key, value in values.items()] + if isinstance(values, dict) + else [(group, values)] + ) + rows.extend( + [key, value if isinstance(value, str) else json.dumps(value)] + for key, value in entries + ) + print(SingleTable(rows).table) + + +@sgx.command('cert-status', help=TEXTS['status']['help']) +@click.option('--json', 'json_format', is_flag=True, help=G_TEXTS['common']['json']) +@click.option('--check', is_flag=True, help=TEXTS['status']['check']) +def cert_status(json_format: bool, check: bool) -> None: + try: + info = get_certificate_status() + except SgxCertificateError as err: + error_exit(str(err), exit_code=CLIExitCodes.OPERATION_EXECUTION_ERROR) + check_error = None + if check: + try: + info['server_version'] = check_certificate(_configured_sgx_url()) + except SgxCertificateError as err: + check_error = str(err) + if json_format: + if check_error: + info['check_error'] = check_error + print(json.dumps(info)) + else: + print_certificate_status(info) + if check_error: + error_exit(check_error, exit_code=CLIExitCodes.OPERATION_EXECUTION_ERROR) + + +@sgx.command('renew', help=TEXTS['renew']['help']) +@click.option( + '--yes', + is_flag=True, + callback=abort_if_false, + expose_value=False, + prompt=TEXTS['renew']['prompt'], +) +@click.option( + '--timeout', + type=int, + default=SGX_SIGN_TIMEOUT, + show_default=True, + help=TEXTS['renew']['timeout'], +) +@click.option('--skip-verify', is_flag=True, help=TEXTS['renew']['skip_verify']) +@check_inited +@check_user +def renew(timeout: int, skip_verify: bool) -> None: + sgx_url = _configured_sgx_url() + try: + result = renew_certificate(sgx_url, timeout=timeout, verify=not skip_verify, log=print) + except SgxCertificateError as err: + error_exit(str(err), exit_code=CLIExitCodes.OPERATION_EXECUTION_ERROR) + print_certificate_status(result) + if result['backup']: + print(TEXTS['renew']['backup'].format(path=result['backup'])) + print(TEXTS['renew']['done']) + + +def _configured_sgx_url() -> str: + try: + sgx_url = get_sgx_url() + except Exception as err: # settings files are missing or invalid + error_exit(f'Cannot read node settings: {err}', exit_code=CLIExitCodes.NODE_STATE_ERROR) + if not sgx_url: + error_exit(TEXTS['no_sgx'], exit_code=CLIExitCodes.NODE_STATE_ERROR) + return sgx_url + + +def print_certificate_status(info: dict) -> None: + present = info['present'] + rows = [ + ['SGX client certificate', ''], + ['Directory', info['directory']], + ['Private key', _presence(present['key'])], + ['Signing request', _presence(present['csr'])], + ['Certificate', _presence(present['crt'])], + ] + if 'subject' in info: + rows.extend( + [ + ['Subject CN', info['subject']], + ['Issuer CN', info['issuer']], + ['Valid from', info['not_valid_before']], + ['Valid until', info['not_valid_after']], + ['Days left', str(info['days_left'])], + ['SHA-256', info['fingerprint_sha256']], + ['Key matches', _yes_no(info['key_matches'])], + ] + ) + if info.get('server_version'): + rows.append(['SGX server', f'accepted the certificate, version {info["server_version"]}']) + print(SingleTable(rows).table) + for notice in _notices(info): + print(notice) + + +def _notices(info: dict) -> list[str]: + notices = [] + if not info['complete']: + notices.append(TEXTS['status']['missing']) + elif info.get('expired'): + notices.append(TEXTS['status']['expired']) + elif info.get('expires_soon'): + notices.append(TEXTS['status']['expires_soon'].format(days=info['days_left'])) + if info.get('key_matches') is False: + notices.append(TEXTS['status']['key_mismatch']) + return notices + + +def _presence(present: bool) -> str: + return 'present' if present else 'missing' + + +def _yes_no(value: bool | None) -> str: + if value is None: + return 'unknown' + return 'yes' if value else 'no' diff --git a/node_cli/configs/__init__.py b/node_cli/configs/__init__.py index 7ed6afee..d65e4613 100644 --- a/node_cli/configs/__init__.py +++ b/node_cli/configs/__init__.py @@ -40,6 +40,8 @@ SKALE_DIR = os.path.join(G_CONF_HOME, '.skale') SKALE_TMP_DIR = os.path.join(SKALE_DIR, '.tmp') +AUTH_DIR = Path(SKALE_DIR) / 'auth' +ADMIN_API_TOKEN_PATH = AUTH_DIR / 'admin-api.token' NODE_DATA_PATH = os.path.join(SKALE_DIR, 'node_data') SCHAIN_NODE_DATA_PATH = os.path.join(NODE_DATA_PATH, 'schains') diff --git a/node_cli/configs/routes.py b/node_cli/configs/routes.py index d26dccb9..3cda6bee 100644 --- a/node_cli/configs/routes.py +++ b/node_cli/configs/routes.py @@ -37,7 +37,7 @@ 'update-safe', ], 'health': ['containers', 'schains'], - 'info': ['sgx'], + 'info': ['sgx', 'sgx-options'], 'schains': ['config', 'list', 'dkg-statuses', 'firewall-rules', 'repair', 'get'], 'ssl': ['status', 'upload'], 'wallet': ['info', 'send-eth'], diff --git a/node_cli/configs/sgx.py b/node_cli/configs/sgx.py new file mode 100644 index 00000000..d37c3a78 --- /dev/null +++ b/node_cli/configs/sgx.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# +# This file is part of node-cli +# +# Copyright (C) 2026 SKALE Labs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import os + +from node_cli.configs import NODE_DATA_PATH, SGX_CERTS_PATH + +# File names are fixed by the sgx client library that node services use; it expects +# exactly these three entries in the certificate directory. +SGX_KEY_FILENAME = 'sgx.key' +SGX_CSR_FILENAME = 'sgx.csr' +SGX_CRT_FILENAME = 'sgx.crt' + +SGX_CERTS_BACKUP_PATH = os.path.join(NODE_DATA_PATH, 'sgx_certs_backup') + +# The SGX wallet signs certificate requests over plain HTTP on the port that follows +# its main port, which is how the sgx client library derives the address as well. +SGX_CSR_SERVER_PORT_OFFSET = 1 + +SGX_KEY_SIZE = 2048 +SGX_RPC_TIMEOUT = 60 +SGX_SIGN_POLL_INTERVAL = 10 +SGX_SIGN_TIMEOUT = 600 +SGX_CERT_EXPIRY_WARNING_DAYS = 30 + +__all__ = [ + 'SGX_CERTS_PATH', + 'SGX_CERTS_BACKUP_PATH', + 'SGX_CERT_EXPIRY_WARNING_DAYS', + 'SGX_CRT_FILENAME', + 'SGX_CSR_FILENAME', + 'SGX_CSR_SERVER_PORT_OFFSET', + 'SGX_KEY_FILENAME', + 'SGX_KEY_SIZE', + 'SGX_RPC_TIMEOUT', + 'SGX_SIGN_POLL_INTERVAL', + 'SGX_SIGN_TIMEOUT', +] diff --git a/node_cli/core/sgx.py b/node_cli/core/sgx.py new file mode 100644 index 00000000..31cfdf99 --- /dev/null +++ b/node_cli/core/sgx.py @@ -0,0 +1,365 @@ +# -*- coding: utf-8 -*- +# +# This file is part of node-cli +# +# Copyright (C) 2026 SKALE Labs +# +# 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 . + +"""SGX wallet options and client certificate management. + +Node services authenticate to the SGX wallet with a client certificate kept in +``node_data/sgx_certs``. The sgx client library inside the containers expects exactly +three files there (``sgx.key``, ``sgx.csr`` and ``sgx.crt``) and issues a certificate on +its own only when one of them is missing, so this module never leaves that directory +incomplete: new material is prepared next to it and moved into place with per-file +atomic renames, and the current certificate is kept until the new one is signed. +Certificate operations talk to the SGX server directly. Server options are queried +through the authenticated node API. +""" + +import datetime +import logging +import os +import secrets +import shutil +import tempfile +import time +import warnings +from collections.abc import Callable +from pathlib import Path +from urllib.parse import urlparse + +import requests +from cryptography import x509 +from cryptography.exceptions import UnsupportedAlgorithm +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from urllib3.exceptions import InsecureRequestWarning + +from node_cli.configs.sgx import ( + SGX_CERT_EXPIRY_WARNING_DAYS, + SGX_CERTS_BACKUP_PATH, + SGX_CERTS_PATH, + SGX_CRT_FILENAME, + SGX_CSR_FILENAME, + SGX_CSR_SERVER_PORT_OFFSET, + SGX_KEY_FILENAME, + SGX_KEY_SIZE, + SGX_RPC_TIMEOUT, + SGX_SIGN_POLL_INTERVAL, + SGX_SIGN_TIMEOUT, +) +from node_cli.utils.helper import get_request + +logger = logging.getLogger(__name__) + +FILE_MODES = {'key': 0o600, 'csr': 0o644, 'crt': 0o644} +INSTALL_ORDER = ('key', 'csr', 'crt') +SIGNING_PENDING = 1 + +Logger = Callable[[str], None] + + +class SgxCertificateError(Exception): + """Raised when the SGX client certificate cannot be read, issued or installed.""" + + +def get_server_options() -> tuple[str, str | dict]: + """Query SGX options through the admin API using the node's CLI credential.""" + return get_request(blueprint='info', method='sgx-options') + + +def certificate_paths(directory: str | Path | None = None) -> dict[str, Path]: + directory = Path(directory or SGX_CERTS_PATH) + return { + 'key': directory / SGX_KEY_FILENAME, + 'csr': directory / SGX_CSR_FILENAME, + 'crt': directory / SGX_CRT_FILENAME, + } + + +def csr_server_url(sgx_url: str) -> str: + """Address of the wallet's signing service, derived the way the sgx client does.""" + parsed = urlparse(sgx_url) + if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.port is None: + raise SgxCertificateError(f'SGX URL must look like https://host:port, got {sgx_url!r}') + host = f'[{parsed.hostname}]' if ':' in parsed.hostname else parsed.hostname + return f'http://{host}:{parsed.port + SGX_CSR_SERVER_PORT_OFFSET}' + + +def get_certificate_status(directory: str | Path | None = None) -> dict: + """Describe the certificate files without contacting the SGX server.""" + paths = certificate_paths(directory) + present = {name: path.is_file() for name, path in paths.items()} + status: dict = { + 'directory': str(paths['crt'].parent), + 'present': present, + 'complete': all(present.values()), + } + if not present['crt']: + return status + cert = _load_certificate(paths['crt']) + now = datetime.datetime.now(datetime.timezone.utc) + try: + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + except AttributeError: + not_before = cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) + not_after = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) + days_left = (not_after - now).days + status.update( + { + 'subject': _common_name(cert.subject), + 'issuer': _common_name(cert.issuer), + 'serial_number': format(cert.serial_number, 'x'), + 'not_valid_before': not_before.isoformat(timespec='seconds'), + 'not_valid_after': not_after.isoformat(timespec='seconds'), + 'days_left': days_left, + 'expired': now >= not_after, + 'not_yet_valid': now < not_before, + 'expires_soon': now < not_after and days_left < SGX_CERT_EXPIRY_WARNING_DAYS, + 'fingerprint_sha256': cert.fingerprint(hashes.SHA256()).hex(':'), + 'key_matches': _key_matches(paths['key'], cert) if present['key'] else None, + } + ) + return status + + +def check_certificate(sgx_url: str, directory: str | Path | None = None) -> str: + """Return the SGX server version obtained while authenticating with the local files.""" + paths = certificate_paths(directory) + missing = [name for name in ('key', 'crt') if not paths[name].is_file()] + if missing: + raise SgxCertificateError( + f'Cannot check the certificate, missing files: {", ".join(missing)}' + ) + return _server_version(sgx_url, (str(paths['crt']), str(paths['key']))) + + +def renew_certificate( + sgx_url: str, + *, + directory: str | Path | None = None, + backup_root: str | Path | None = None, + timeout: int = SGX_SIGN_TIMEOUT, + verify: bool = True, + log: Logger | None = None, +) -> dict: + """Issue a new client certificate and install it, keeping the current one until then. + + The new certificate is tested against the SGX server before it replaces the current + files unless ``verify`` is off. Previous files are copied under ``backup_root``. + """ + paths = certificate_paths(directory) + certs_dir = paths['crt'].parent + csr_url = csr_server_url(sgx_url) + try: + os.makedirs(certs_dir, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix='.sgx_certs.', dir=_staging_parent(certs_dir))) + except OSError as err: + raise SgxCertificateError( + f'Cannot prepare certificate files in {certs_dir}: {err}' + ) from err + + _say(log, 'Generating a new RSA key and certificate signing request ...') + key_pem, csr_pem = _generate_key_and_csr() + try: + staged = {name: staging / path.name for name, path in paths.items()} + _write_file(staged['key'], key_pem, FILE_MODES['key']) + _write_file(staged['csr'], csr_pem, FILE_MODES['csr']) + crt_pem = _request_signed_certificate(csr_url, csr_pem.decode('ascii'), timeout, log) + _write_file(staged['crt'], crt_pem.encode('utf-8'), FILE_MODES['crt']) + if not _key_matches(staged['key'], _load_certificate(staged['crt'])): + raise SgxCertificateError( + 'The certificate returned by the SGX server does not match the generated key' + ) + version = None + if verify: + _say(log, f'Verifying the new certificate against {sgx_url} ...') + version = _server_version(sgx_url, (str(staged['crt']), str(staged['key']))) + _say(log, f'SGX server (version {version}) accepted the new certificate') + try: + backup = _backup_existing(paths, Path(backup_root or SGX_CERTS_BACKUP_PATH)) + _install(staged, paths) + except OSError as err: + raise SgxCertificateError(f'Cannot install the new certificate files: {err}') from err + finally: + shutil.rmtree(staging, ignore_errors=True) + _say(log, f'Installed the new certificate in {certs_dir}') + return { + 'backup': str(backup) if backup else None, + 'server_version': version, + **get_certificate_status(certs_dir), + } + + +def _say(log: Logger | None, message: str) -> None: + logger.info(message) + if log is not None: + log(message) + + +def _rpc( + url: str, method: str, params: dict | None = None, cert: tuple[str, str] | None = None +) -> dict: + payload = {'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params or {}} + try: + with warnings.catch_warnings(): + # The SGX wallet presents a self-signed server certificate; node services + # skip its verification the same way and rely on client authentication. + warnings.simplefilter('ignore', InsecureRequestWarning) + response = requests.post( + url, json=payload, cert=cert, verify=False, timeout=SGX_RPC_TIMEOUT + ) + response.raise_for_status() + data = response.json() + except requests.exceptions.SSLError as err: + raise SgxCertificateError(f'SGX server {url} rejected the TLS connection: {err}') from err + except (requests.exceptions.RequestException, ValueError) as err: + raise SgxCertificateError(f'Cannot call {method} on {url}: {err}') from err + if not isinstance(data, dict): + raise SgxCertificateError(f'{method} on {url} returned an unexpected response') + if data.get('error'): + error = data['error'] + message = error.get('message', error) if isinstance(error, dict) else error + raise SgxCertificateError(f'{method} on {url} failed: {message}') + result = data.get('result') + if not isinstance(result, dict): + raise SgxCertificateError(f'{method} on {url} returned an unexpected response') + return result + + +def _raise_on_status(result: dict, method: str) -> None: + if result.get('status', 0) != 0: + message = result.get('errorMessage') or f'status {result.get("status")}' + raise SgxCertificateError(f'SGX server refused {method}: {message}') + + +def _server_version(sgx_url: str, cert: tuple[str, str]) -> str: + result = _rpc(sgx_url, 'getServerVersion', cert=cert) + _raise_on_status(result, 'getServerVersion') + return str(result.get('version') or 'unknown') + + +def _request_signed_certificate( + csr_url: str, csr_pem: str, timeout: int, log: Logger | None +) -> str: + result = _rpc(csr_url, 'signCertificate', {'certificate': csr_pem}) + _raise_on_status(result, 'signCertificate') + csr_hash = result.get('hash') + if not csr_hash: + raise SgxCertificateError('SGX server did not return a hash for the signing request') + _say(log, f'Signing request accepted by {csr_url}, hash: {csr_hash}') + + deadline = time.monotonic() + timeout + waiting = False + while True: + result = _rpc(csr_url, 'getCertificate', {'hash': csr_hash}) + if result.get('status', 0) == 0 and result.get('cert'): + return str(result['cert']) + if result.get('status', 0) not in (0, SIGNING_PENDING): + _raise_on_status(result, 'getCertificate') + if not waiting: + _say( + log, + 'Waiting for the SGX server to sign the request. ' + 'If the server requires manual confirmation, approve the hash above there.', + ) + waiting = True + if time.monotonic() >= deadline: + raise SgxCertificateError( + f'SGX server did not sign the request within {timeout} seconds ' + f'(request hash {csr_hash}); the current certificate is unchanged' + ) + time.sleep(SGX_SIGN_POLL_INTERVAL) + + +def _generate_key_and_csr() -> tuple[bytes, bytes]: + key = rsa.generate_private_key(public_exponent=65537, key_size=SGX_KEY_SIZE) + # The sgx client library names its requests after a random hex string as well. + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, secrets.token_hex(32))]) + csr = x509.CertificateSigningRequestBuilder().subject_name(subject).sign(key, hashes.SHA256()) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + return key_pem, csr.public_bytes(serialization.Encoding.PEM) + + +def _load_certificate(path: Path) -> x509.Certificate: + try: + return x509.load_pem_x509_certificate(path.read_bytes()) + except (OSError, ValueError) as err: + raise SgxCertificateError(f'Cannot read certificate {path}: {err}') from err + + +def _key_matches(key_path: Path, cert: x509.Certificate) -> bool: + try: + key = serialization.load_pem_private_key(key_path.read_bytes(), password=None) + except (OSError, ValueError, TypeError, UnsupportedAlgorithm): + return False + encoding = serialization.Encoding.DER + fmt = serialization.PublicFormat.SubjectPublicKeyInfo + return key.public_key().public_bytes(encoding, fmt) == cert.public_key().public_bytes( + encoding, fmt + ) + + +def _common_name(name: x509.Name) -> str: + attributes = name.get_attributes_for_oid(NameOID.COMMON_NAME) + return str(attributes[0].value) if attributes else name.rfc4514_string() + + +def _staging_parent(certs_dir: Path) -> Path: + """Directory on the same filesystem as the certificates, so renames stay atomic.""" + parent = certs_dir.parent + if certs_dir.stat().st_dev == parent.stat().st_dev: + return parent + return certs_dir + + +def _write_file(path: Path, data: bytes, mode: int) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + with os.fdopen(fd, 'wb') as target: + os.fchmod(target.fileno(), mode) + target.write(data) + target.flush() + os.fsync(target.fileno()) + + +def _backup_existing(paths: dict[str, Path], backup_root: Path) -> Path | None: + existing = [path for path in paths.values() if path.is_file()] + if not existing: + return None + backup_root.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(backup_root, 0o700) + stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ') + destination = Path(tempfile.mkdtemp(prefix=f'{stamp}.', dir=backup_root)) + for path in existing: + shutil.copy2(path, destination / path.name) + return destination + + +def _install(staged: dict[str, Path], paths: dict[str, Path]) -> None: + """Move the staged files over the current ones, preserving their ownership.""" + as_root = os.geteuid() == 0 + for name in INSTALL_ORDER: + target = paths[name] + if as_root and target.exists(): + info = target.stat() + os.chown(staged[name], info.st_uid, info.st_gid) + os.replace(staged[name], target) diff --git a/node_cli/main.py b/node_cli/main.py index 9016f79d..b04b3869 100644 --- a/node_cli/main.py +++ b/node_cli/main.py @@ -36,6 +36,7 @@ from node_cli.cli.schains import schains_cli from node_cli.cli.wallet import wallet_cli from node_cli.cli.ssl import ssl_cli +from node_cli.cli.sgx import sgx_cli from node_cli.cli.passive_node import passive_node_cli from node_cli.cli.fair_boot import fair_boot_cli from node_cli.cli.fair_node import fair_node_cli @@ -106,6 +107,7 @@ def get_command_groups() -> List[click.Group]: staking_cli, wallet_cli, ssl_cli, + sgx_cli, ] else: return [ # type: ignore @@ -116,6 +118,7 @@ def get_command_groups() -> List[click.Group]: passive_node_cli, wallet_cli, ssl_cli, + sgx_cli, exit_cli, lvmpy_cli, ] diff --git a/node_cli/utils/api_auth.py b/node_cli/utils/api_auth.py new file mode 100644 index 00000000..b9c48ae1 --- /dev/null +++ b/node_cli/utils/api_auth.py @@ -0,0 +1,86 @@ +"""Provision and read the per-node admin API credential without logging it.""" + +import os +import pwd +import re +import secrets +import stat +import tempfile +from pathlib import Path + +from node_cli.configs import ADMIN_API_TOKEN_PATH, G_CONF_USER + + +class APIAuthError(RuntimeError): + pass + + +def read_api_token() -> str | None: + try: + fd = os.open(ADMIN_API_TOKEN_PATH, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + # Allows the updated CLI to talk to the old API during the first upgrade. + return None + except OSError as err: + raise APIAuthError('Cannot read the admin API credential') from err + try: + with os.fdopen(fd, 'r', encoding='ascii') as token_file: + info = os.fstat(token_file.fileno()) + if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600: + raise ValueError('Invalid token file permissions') + token = token_file.read(66).removesuffix('\n') + if not re.fullmatch(r'[0-9a-f]{64}', token): + raise ValueError('Invalid token file contents') + except (OSError, ValueError) as err: + raise APIAuthError( + 'Admin API credential must be a valid token in a mode 0600 file' + ) from err + return token + + +def ensure_api_token() -> None: + """Publish a complete credential atomically; never overwrite an existing token.""" + owner = pwd.getpwnam(G_CONF_USER) + if os.geteuid() not in (0, owner.pw_uid): + raise APIAuthError('Only the configured node user or root can provision the API credential') + + path = Path(ADMIN_API_TOKEN_PATH) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + # Restrict the dedicated directory as well as the token. In particular, a + # root-run CLI must leave it traversable by the configured node user. + try: + directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + if os.geteuid() == 0: + os.fchown(directory_fd, owner.pw_uid, owner.pw_gid) + os.fchmod(directory_fd, 0o700) + finally: + os.close(directory_fd) + except OSError as err: + raise APIAuthError('Cannot secure the admin API auth directory') from err + + if read_api_token() is not None: + return + + fd, tmp_path = tempfile.mkstemp(prefix='.admin-api-', dir=path.parent) + try: + with os.fdopen(fd, 'w', encoding='ascii') as token_file: + if os.geteuid() == 0: + os.fchown(token_file.fileno(), owner.pw_uid, owner.pw_gid) + token_file.write(secrets.token_hex(32) + '\n') + token_file.flush() + os.fsync(token_file.fileno()) + try: + os.link(tmp_path, path) + except FileExistsError: + # Another CLI process may have provisioned it concurrently. + pass + finally: + os.unlink(tmp_path) + if read_api_token() is None: + raise APIAuthError('Admin API credential disappeared during provisioning') + + +def get_api_headers() -> dict[str, str]: + token = read_api_token() + return {'Authorization': f'Bearer {token}'} if token is not None else {} diff --git a/node_cli/utils/docker_utils.py b/node_cli/utils/docker_utils.py index 33c20fe9..b9b8e547 100644 --- a/node_cli/utils/docker_utils.py +++ b/node_cli/utils/docker_utils.py @@ -38,6 +38,7 @@ REMOVED_CONTAINERS_FOLDER_PATH, ) from node_cli.core.node_options import active_fair, active_skale, passive_fair, passive_skale +from node_cli.utils.api_auth import ensure_api_token from node_cli.utils.helper import run_cmd from node_cli.utils.node_type import NodeMode, NodeType @@ -341,6 +342,7 @@ def compose_up( is_fair_boot: bool = False, services: list[str] | None = None, ): + ensure_api_token() env['PASSIVE_NODE'] = str(node_mode == NodeMode.PASSIVE) if passive_skale(node_type, node_mode) or passive_fair(node_type, node_mode): logger.info('Running containers for passive node') diff --git a/node_cli/utils/helper.py b/node_cli/utils/helper.py index 08ea4f57..2ae9ab50 100644 --- a/node_cli/utils/helper.py +++ b/node_cli/utils/helper.py @@ -58,6 +58,7 @@ STREAM_LOG_FORMAT, ) from node_cli.configs.routes import get_route +from node_cli.utils.api_auth import APIAuthError, get_api_headers from node_cli.utils.exit_codes import CLIExitCodes from node_cli.utils.global_config import get_system_user, read_g_config from node_cli.utils.print_formatters import print_err_response @@ -66,6 +67,11 @@ HOST = f'http://{ADMIN_HOST}:{ADMIN_PORT}' +# Local operator credentials must not be sent through environment proxies or +# replaced by netrc credentials. +api_session = requests.Session() +api_session.trust_env = False + DEFAULT_ERROR_DATA = { 'status': 'error', 'payload': 'Request failed. Check API container logs', @@ -195,8 +201,12 @@ def post_request(blueprint, method, json=None, files=None): route = get_route(blueprint, method) url = construct_url(route) try: - response = requests.post(url, json=json, files=files) + response = api_session.post( + url, json=json, files=files, headers=get_api_headers(), allow_redirects=False + ) data = response.json() + except APIAuthError as err: + return 'error', str(err) except Exception as err: logger.exception('Request failed', exc_info=err) data = DEFAULT_ERROR_DATA @@ -211,8 +221,12 @@ def get_request( route = get_route(blueprint, method) url = construct_url(route) try: - response = requests.get(url, params=params) + response = api_session.get( + url, params=params, headers=get_api_headers(), allow_redirects=False + ) data = response.json() + except APIAuthError as err: + return 'error', str(err) except Exception as err: logger.exception('Request failed', exc_info=err) data = DEFAULT_ERROR_DATA diff --git a/node_cli/utils/settings.py b/node_cli/utils/settings.py index a2a4858e..378ef4df 100644 --- a/node_cli/utils/settings.py +++ b/node_cli/utils/settings.py @@ -30,6 +30,7 @@ InternalSettings, SkalePassiveSettings, SkaleSettings, + get_settings, write_internal_settings_file, write_node_settings_file, ) @@ -85,3 +86,9 @@ def save_internal_settings( InternalSettings.model_validate(data) _remove_if_exists(INTERNAL_SETTINGS_PATH) write_internal_settings_file(path=INTERNAL_SETTINGS_PATH, data=data) + + +def get_sgx_url() -> str | None: + """SGX server URL of the node, or None when its mode has no SGX server (passive).""" + sgx_url = getattr(get_settings(), 'sgx_url', None) + return str(sgx_url).rstrip('/') if sgx_url else None diff --git a/tests/cli/exit_test.py b/tests/cli/exit_test.py index a3b26ed0..83b3f077 100644 --- a/tests/cli/exit_test.py +++ b/tests/cli/exit_test.py @@ -9,7 +9,7 @@ def test_exit_status(): resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock( - 'node_cli.utils.helper.requests.get', resp_mock, status, ['--format', 'json'] + 'node_cli.utils.helper.api_session.get', resp_mock, status, ['--format', 'json'] ) assert result.exit_code == 0 assert ( diff --git a/tests/cli/health_test.py b/tests/cli/health_test.py index 404d9a73..1d7b58db 100644 --- a/tests/cli/health_test.py +++ b/tests/cli/health_test.py @@ -47,7 +47,7 @@ def test_containers(): resp_mock = response_mock(requests.codes.ok, json_data=OK_LS_RESPONSE_DATA) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, containers) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, containers) assert result.exit_code == 0 assert ( result.output @@ -73,7 +73,7 @@ def test_checks(): } ] resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, schains) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, schains) assert result.exit_code == 0 assert ( @@ -81,7 +81,7 @@ def test_checks(): == 'sChain Name Config directory DKG Config file Volume Container IMA Firewall RPC Blocks\n-------------------------------------------------------------------------------------------------------------\ntest_schain True False False False False False False False False \n' # noqa ) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, schains, ['--json']) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, schains, ['--json']) assert result.exit_code == 0 assert ( @@ -99,7 +99,7 @@ def test_sgx_status(): 'status_https': True, } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, sgx) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, sgx) assert result.exit_code == 0 assert ( diff --git a/tests/cli/node_test.py b/tests/cli/node_test.py index 9ba85528..f24ef487 100644 --- a/tests/cli/node_test.py +++ b/tests/cli/node_test.py @@ -79,7 +79,7 @@ def test_register_node(inited_node, resource_alloc, mocked_g_config): mock.patch('node_cli.core.node.get_settings'), ): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, register_node, ['--name', 'test-node', '--ip', '0.0.0.0', '--port', '8080', '-d', 'skale.test'], @@ -104,7 +104,7 @@ def test_register_node_firewall_failure(inited_node, resource_alloc, mocked_g_co mock.patch('node_cli.core.node.get_settings'), ): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, register_node, ['--name', 'test-node', '--ip', '0.0.0.0', '--port', '8080', '-d', 'skale.test'], @@ -129,7 +129,7 @@ def test_register_node_with_error(inited_node, resource_alloc, mocked_g_config): ) with mock.patch('node_cli.utils.decorators.is_node_inited', return_value=True): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, register_node, ['--name', 'test-node2', '--ip', '0.0.0.0', '--port', '80', '-d', 'skale.test'], @@ -150,7 +150,7 @@ def test_register_node_with_prompted_ip(inited_node, resource_alloc, mocked_g_co mock.patch('node_cli.core.node.get_settings'), ): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, register_node, ['--name', 'test-node', '--port', '8080', '-d', 'skale.test'], @@ -172,7 +172,7 @@ def test_register_node_with_default_port(inited_node, resource_alloc, mocked_g_c mock.patch('node_cli.core.node.get_settings'), ): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, register_node, ['--name', 'test-node', '-d', 'skale.test'], @@ -188,7 +188,7 @@ def test_register_node_with_default_port(inited_node, resource_alloc, mocked_g_c def test_register_with_no_alloc(mocked_g_config): resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, register_node, ['--name', 'test-node', '-d', 'skale.test'], @@ -221,7 +221,7 @@ def test_node_info_node_info(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, node_info) assert result.exit_code == 0 assert ( result.output @@ -249,7 +249,7 @@ def test_node_info_node_info_not_created(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, node_info) assert result.exit_code == 0 assert result.output == 'This SKALE node is not registered on SKALE Manager yet\n' @@ -274,7 +274,7 @@ def test_node_info_node_info_frozen(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, node_info) assert result.exit_code == 0 assert ( result.output @@ -302,7 +302,7 @@ def test_node_info_node_info_left(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, node_info) assert result.exit_code == 0 assert ( result.output @@ -330,7 +330,7 @@ def test_node_info_node_info_leaving(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, node_info) assert result.exit_code == 0 assert ( result.output @@ -358,7 +358,7 @@ def test_node_info_node_info_in_maintenance(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, node_info) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, node_info) assert result.exit_code == 0 assert ( result.output @@ -370,7 +370,7 @@ def test_node_signature(): signature_sample = '0x1231231231' response_data = {'status': 'ok', 'payload': {'signature': signature_sample}} resp_mock = response_mock(requests.codes.ok, json_data=response_data) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, signature, ['1']) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, signature, ['1']) assert result.exit_code == 0 assert result.output == f'Signature: {signature_sample}\n' @@ -423,7 +423,7 @@ def test_restore(request, node_type, node_mode, test_user_conf, mocked_g_config, def test_maintenance_on(): resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) result = run_command_mock( - 'node_cli.utils.helper.requests.post', resp_mock, set_node_in_maintenance, ['--yes'] + 'node_cli.utils.helper.api_session.post', resp_mock, set_node_in_maintenance, ['--yes'] ) assert result.exit_code == 0 assert ( @@ -435,7 +435,7 @@ def test_maintenance_on(): def test_maintenance_off(mocked_g_config): resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) result = run_command_mock( - 'node_cli.utils.helper.requests.post', resp_mock, remove_node_from_maintenance + 'node_cli.utils.helper.api_session.post', resp_mock, remove_node_from_maintenance ) assert result.exit_code == 0 assert ( @@ -455,7 +455,7 @@ def test_turn_off_maintenance_on( mock.patch('node_cli.cli.node.TYPE', NodeType.SKALE), ): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, _turn_off, ['--maintenance-on', '--yes'], @@ -468,7 +468,7 @@ def test_turn_off_maintenance_on( assert result.exit_code == 0 with mock.patch('node_cli.utils.docker_utils.is_container_running', return_value=True): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, _turn_off, ['--maintenance-on', '--yes'], @@ -489,7 +489,7 @@ def test_turn_on_maintenance_off( mock.patch('node_cli.cli.node.TYPE', NodeType.SKALE), ): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, _turn_on, [regular_user_conf.as_posix(), '--maintenance-off', '--sync-schains', '--yes'], @@ -507,7 +507,7 @@ def test_set_domain_name(): with mock.patch('node_cli.utils.decorators.is_node_inited', return_value=True): result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, _set_domain_name, ['-d', 'skale.test', '--yes'], diff --git a/tests/cli/schains_test.py b/tests/cli/schains_test.py index ffefdea8..a15a699f 100644 --- a/tests/cli/schains_test.py +++ b/tests/cli/schains_test.py @@ -79,7 +79,7 @@ def test_ls(): }, ] resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, ls) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, ls) assert result.exit_code == 0 assert ( result.output @@ -100,14 +100,14 @@ def test_dkg(): } ] resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, dkg) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, dkg) assert result.exit_code == 0 assert ( result.output == ' sChain Name DKG Status Added At sChain Status\n---------------------------------------------------------------------\nmelodic-aldhibah IN_PROGRESS Jan 08 2020 15:26:52 Exists \n' # noqa ) - result = run_command_mock('node_cli.utils.helper.requests.get', resp_mock, dkg, ['--all']) + result = run_command_mock('node_cli.utils.helper.api_session.get', resp_mock, dkg, ['--all']) assert result.exit_code == 0 assert ( result.output @@ -164,7 +164,7 @@ def test_get_schain_config(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock( - 'node_cli.utils.helper.requests.get', resp_mock, get_schain_config, ['test1'] + 'node_cli.utils.helper.api_session.get', resp_mock, get_schain_config, ['test1'] ) assert result.exit_code == 0 assert ( @@ -189,7 +189,7 @@ def test_schain_rules(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock( - 'node_cli.utils.helper.requests.get', resp_mock, show_rules, ['schain-test'] + 'node_cli.utils.helper.api_session.get', resp_mock, show_rules, ['schain-test'] ) assert result.exit_code == 0 print(repr(result.output)) @@ -221,7 +221,7 @@ def test_info(): } resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'ok'}) result = run_command_mock( - 'node_cli.utils.helper.requests.get', resp_mock, info_, ['attractive-ed-asich'] + 'node_cli.utils.helper.api_session.get', resp_mock, info_, ['attractive-ed-asich'] ) assert ( result.output @@ -232,7 +232,7 @@ def test_info(): payload = ['error'] resp_mock = response_mock(requests.codes.ok, json_data={'payload': payload, 'status': 'error'}) result = run_command_mock( - 'node_cli.utils.helper.requests.get', resp_mock, info_, ['schain not found'] + 'node_cli.utils.helper.api_session.get', resp_mock, info_, ['schain not found'] ) assert ( result.output diff --git a/tests/cli/sgx_test.py b/tests/cli/sgx_test.py new file mode 100644 index 00000000..6e5c2c47 --- /dev/null +++ b/tests/cli/sgx_test.py @@ -0,0 +1,197 @@ +import json +from unittest.mock import Mock + +import pytest +import requests_mock + +from node_cli.cli.sgx import cert_status, options, renew, sgx_cli +from node_cli.core import sgx as core_sgx +from node_cli.utils import api_auth, helper +from node_cli.utils.exit_codes import CLIExitCodes +from tests.fixtures.settings import NODE_SKALE_ACTIVE +from tests.fixtures.sgx import FakeSgxWallet +from tests.helper import run_command + +SETTINGS_SGX_URL = NODE_SKALE_ACTIVE['sgx_url'] + + +@pytest.fixture +def certs_dir(tmp_path, monkeypatch): + directory = tmp_path / 'node_data' / 'sgx_certs' + directory.mkdir(parents=True) + monkeypatch.setattr(core_sgx, 'SGX_CERTS_PATH', str(directory)) + monkeypatch.setattr(core_sgx, 'SGX_CERTS_BACKUP_PATH', str(directory.parent / 'backup')) + monkeypatch.setattr(core_sgx, 'SGX_SIGN_POLL_INTERVAL', 0) + return directory + + +@pytest.fixture +def rpc(): + with requests_mock.Mocker() as mock: + yield mock + + +@pytest.fixture +def api_token(tmp_path, monkeypatch): + path = tmp_path / 'admin-api.token' + path.write_text('ab' * 32 + '\n') + path.chmod(0o600) + monkeypatch.setattr(api_auth, 'ADMIN_API_TOKEN_PATH', path) + return 'ab' * 32 + + +@pytest.mark.parametrize('json_format', [False, True]) +def test_options_through_admin( + rpc, api_token, inited_node, skale_active_settings, mocked_g_config, json_format +): + payload = { + 'flags': {'auto_sign': False, 'log_level': 2}, + 'effective': {'rpc_port': 1026, 'rpc_client_certificate_required': True}, + 'build': None, + } + rpc.get( + helper.construct_url('/api/v1/info/sgx-options'), + json={'status': 'ok', 'payload': payload}, + ) + args = ['sgx', 'options'] + (['--json'] if json_format else []) + result = run_command(sgx_cli, args) + assert result.exit_code == 0, result.output + if json_format: + assert json.loads(result.output) == payload + else: + assert 'SGX option' in result.output + assert 'flags.auto_sign' in result.output + assert 'false' in result.output + assert len(rpc.request_history) == 1 + assert rpc.last_request.method == 'GET' + assert rpc.last_request.headers['Authorization'] == f'Bearer {api_token}' + + +@pytest.mark.parametrize( + ('code', 'message'), + [(401, 'A valid node CLI credential is required'), (503, 'SGX server unavailable')], +) +def test_options_reports_api_errors( + rpc, api_token, inited_node, skale_active_settings, mocked_g_config, code, message +): + rpc.get( + helper.construct_url('/api/v1/info/sgx-options'), + status_code=code, + json={'status': 'error', 'payload': message}, + ) + result = run_command(options, ['--json']) + assert result.exit_code == CLIExitCodes.BAD_API_RESPONSE.value + assert message in result.output + + +def test_options_needs_an_sgx_node( + rpc, api_token, inited_node, skale_passive_settings, mocked_g_config +): + result = run_command(options) + assert result.exit_code == CLIExitCodes.NODE_STATE_ERROR.value + assert 'no SGX server configured' in result.output + assert not rpc.called + + +def test_cert_status_without_certificate(certs_dir): + result = run_command(sgx_cli, ['sgx', 'cert-status']) + assert result.exit_code == 0 + assert 'Private key' in result.output + # three file rows plus the notice + assert result.output.count('missing') == 4 + assert 'skale sgx renew' in result.output + + +def test_cert_status_shows_certificate_details(certs_dir, rpc): + FakeSgxWallet(rpc).issue_files(certs_dir) + result = run_command(cert_status) + assert result.exit_code == 0 + assert 'sgx-wallet-ca' in result.output + assert 'yes' in result.output + assert 'renew' not in result.output + + +def test_cert_status_json(certs_dir, rpc): + FakeSgxWallet(rpc).issue_files(certs_dir) + result = run_command(cert_status, ['--json']) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data['complete'] is True + assert data['key_matches'] is True + assert data['issuer'] == 'sgx-wallet-ca' + + +def test_cert_status_check_uses_configured_sgx_url(certs_dir, rpc, skale_active_settings): + wallet = FakeSgxWallet(rpc, SETTINGS_SGX_URL) + wallet.issue_files(certs_dir) + result = run_command(cert_status, ['--check']) + assert result.exit_code == 0, result.output + assert 'accepted the certificate, version 1.83.0' in result.output + assert wallet.calls == [('sgx', 'getServerVersion')] + + +def test_cert_status_check_reports_rejection(certs_dir, rpc, skale_active_settings): + FakeSgxWallet(rpc, SETTINGS_SGX_URL, reject_clients=True).issue_files(certs_dir) + result = run_command(cert_status, ['--check', '--json']) + assert result.exit_code == CLIExitCodes.OPERATION_EXECUTION_ERROR.value + assert 'rejected the TLS connection' in result.output + + +def test_cert_status_check_needs_an_sgx_node(certs_dir, skale_passive_settings): + result = run_command(cert_status, ['--check']) + assert result.exit_code == CLIExitCodes.NODE_STATE_ERROR.value + assert 'no SGX server configured' in result.output + + +def test_renew_asks_for_confirmation(certs_dir, monkeypatch): + core = Mock(side_effect=AssertionError('must not run')) + monkeypatch.setattr('node_cli.cli.sgx.renew_certificate', core) + result = run_command(renew, input='n\n') + assert result.exit_code == 1 + assert 'Aborted' in result.output + core.assert_not_called() + + +def test_renew_replaces_certificate( + certs_dir, rpc, inited_node, skale_active_settings, mocked_g_config +): + wallet = FakeSgxWallet(rpc, SETTINGS_SGX_URL) + wallet.issue_files(certs_dir) + before = {path.name: path.read_bytes() for path in certs_dir.iterdir()} + result = run_command(renew, ['--yes']) + assert result.exit_code == 0, result.output + assert 'hash: ' in result.output + assert 'accepted the new certificate' in result.output + assert 'Previous certificate files were copied to' in result.output + assert 'New SGX client certificate installed' in result.output + after = {path.name: path.read_bytes() for path in certs_dir.iterdir()} + assert sorted(after) == ['sgx.crt', 'sgx.csr', 'sgx.key'] + assert after != before + assert wallet.calls[-1] == ('sgx', 'getServerVersion') + + +def test_renew_skip_verify_and_timeout_flags( + certs_dir, rpc, inited_node, skale_active_settings, mocked_g_config +): + wallet = FakeSgxWallet(rpc, SETTINGS_SGX_URL, reject_clients=True) + result = run_command(renew, ['--yes', '--skip-verify', '--timeout', '5']) + assert result.exit_code == 0, result.output + assert ('sgx', 'getServerVersion') not in wallet.calls + assert 'Previous certificate files' not in result.output + + +def test_renew_needs_an_sgx_node(certs_dir, inited_node, skale_passive_settings, mocked_g_config): + result = run_command(renew, ['--yes']) + assert result.exit_code == CLIExitCodes.NODE_STATE_ERROR.value + assert 'no SGX server configured' in result.output + + +def test_renew_reports_failures( + certs_dir, inited_node, skale_active_settings, mocked_g_config, monkeypatch +): + core = Mock(side_effect=core_sgx.SgxCertificateError('SGX server refused signCertificate')) + monkeypatch.setattr('node_cli.cli.sgx.renew_certificate', core) + result = run_command(renew, ['--yes']) + assert result.exit_code == CLIExitCodes.OPERATION_EXECUTION_ERROR.value + assert 'SGX server refused signCertificate' in result.output + core.assert_called_once_with(SETTINGS_SGX_URL, timeout=600, verify=True, log=print) diff --git a/tests/cli/wallet_test.py b/tests/cli/wallet_test.py index 234f4f1a..306cd823 100644 --- a/tests/cli/wallet_test.py +++ b/tests/cli/wallet_test.py @@ -35,7 +35,7 @@ def test_wallet_info(): response_mock = MagicMock() response_mock.status_code = requests.codes.ok response_mock.json = Mock(return_value=response_data) - result = run_command_mock('node_cli.utils.helper.requests.get', response_mock, wallet_info) + result = run_command_mock('node_cli.utils.helper.api_session.get', response_mock, wallet_info) assert result.exit_code == 0 expected = ( '--------------------------------------------------\n' @@ -47,7 +47,7 @@ def test_wallet_info(): assert result.output == expected result = run_command_mock( - 'node_cli.utils.helper.requests.get', response_mock, wallet_info, ['--format', 'json'] + 'node_cli.utils.helper.api_session.get', response_mock, wallet_info, ['--format', 'json'] ) assert result.exit_code == 0 expected = '{"address": "simple_address", "eth_balance": 13, "skale_balance": 123}\n' @@ -57,7 +57,7 @@ def test_wallet_info(): def test_wallet_send(): resp_mock = response_mock(requests.codes.ok, {'status': 'ok', 'payload': None}) result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, send, ['0x00000000000000000000000000000000', '10', '--yes'], @@ -72,7 +72,7 @@ def test_wallet_send_with_error(): {'status': 'error', 'payload': ['Strange error']}, ) result = run_command_mock( - 'node_cli.utils.helper.requests.post', + 'node_cli.utils.helper.api_session.post', resp_mock, send, ['0x00000000000000000000000000000000', '10', '--yes'], diff --git a/tests/core/core_node_test.py b/tests/core/core_node_test.py index b28719d4..4c707c67 100644 --- a/tests/core/core_node_test.py +++ b/tests/core/core_node_test.py @@ -299,7 +299,7 @@ def test_update_node(regular_user_conf, mocked_g_config, resource_file, inited_n ), ): with mock.patch( - 'node_cli.utils.helper.requests.get', return_value=safe_update_api_response() + 'node_cli.utils.helper.api_session.get', return_value=safe_update_api_response() ): # noqa result = update( regular_user_conf.as_posix(), @@ -320,7 +320,7 @@ def test_update_node(regular_user_conf, mocked_g_config, resource_file, inited_n ) @mock.patch('node_cli.core.node.is_admin_running', return_value=False) @mock.patch('node_cli.core.node.is_api_running', return_value=False) -@mock.patch('node_cli.utils.helper.requests.get') +@mock.patch('node_cli.utils.helper.api_session.get') def test_is_update_safe_when_admin_and_api_not_running( mock_requests_get, mock_is_api_running, mock_is_admin_running, node_type, node_mode ): @@ -330,7 +330,7 @@ def test_is_update_safe_when_admin_and_api_not_running( @mock.patch('node_cli.core.node.is_admin_running', return_value=False) @mock.patch('node_cli.core.node.is_api_running', return_value=True) -@mock.patch('node_cli.utils.helper.requests.get') +@mock.patch('node_cli.utils.helper.api_session.get') def test_is_update_safe_when_admin_not_running_for_passive( mock_requests_get, mock_is_api_running, mock_is_admin_running ): @@ -352,7 +352,7 @@ def test_is_update_safe_when_admin_not_running_for_passive( ids=['api_safe', 'api_unsafe'], ) @mock.patch('node_cli.core.node.is_admin_running', return_value=True) -@mock.patch('node_cli.utils.helper.requests.get') +@mock.patch('node_cli.utils.helper.api_session.get') def test_is_update_safe_when_admin_running( mock_requests_get, mock_is_admin_running, api_is_safe, expected_result, node_type, node_mode ): @@ -369,7 +369,7 @@ def test_is_update_safe_when_admin_running( ) @mock.patch('node_cli.core.node.is_admin_running', return_value=False) @mock.patch('node_cli.core.node.is_api_running', return_value=True) -@mock.patch('node_cli.utils.helper.requests.get') +@mock.patch('node_cli.utils.helper.api_session.get') def test_is_update_safe_when_only_api_running_for_regular( mock_requests_get, mock_is_api_running, @@ -392,7 +392,7 @@ def test_is_update_safe_when_only_api_running_for_regular( ], ) @mock.patch('node_cli.core.node.is_admin_running', return_value=True) -@mock.patch('node_cli.utils.helper.requests.get') +@mock.patch('node_cli.utils.helper.api_session.get') def test_is_update_safe_when_api_call_fails( mock_requests_get, mock_is_admin_running, node_type, node_mode ): diff --git a/tests/core/core_sgx_test.py b/tests/core/core_sgx_test.py new file mode 100644 index 00000000..618e9606 --- /dev/null +++ b/tests/core/core_sgx_test.py @@ -0,0 +1,287 @@ +import datetime +import json +import stat +from pathlib import Path + +import pytest +import requests_mock + +from node_cli.core import sgx +from tests.fixtures.sgx import SGX_URL, FakeSgxWallet + +CERT_FILES = ['sgx.crt', 'sgx.csr', 'sgx.key'] + + +@pytest.fixture(autouse=True) +def fast_polling(monkeypatch): + monkeypatch.setattr(sgx, 'SGX_SIGN_POLL_INTERVAL', 0) + + +@pytest.fixture +def rpc(): + with requests_mock.Mocker() as mock: + yield mock + + +@pytest.fixture +def certs_dir(tmp_path): + directory = tmp_path / 'node_data' / 'sgx_certs' + directory.mkdir(parents=True) + return directory + + +def snapshot(directory: Path) -> dict[str, bytes]: + return {path.name: path.read_bytes() for path in directory.iterdir()} + + +def names(directory: Path) -> list[str]: + return sorted(path.name for path in directory.iterdir()) + + +@pytest.mark.parametrize( + 'sgx_url, expected', + [ + ('https://sgx.example.com:1026', 'http://sgx.example.com:1027'), + ('http://127.0.0.1:2026/', 'http://127.0.0.1:2027'), + ('https://[fd00::1]:1026', 'http://[fd00::1]:1027'), + ], +) +def test_csr_server_url(sgx_url, expected): + assert sgx.csr_server_url(sgx_url) == expected + + +@pytest.mark.parametrize('sgx_url', ['https://sgx.example.com', 'ftp://host:1026', 'nonsense']) +def test_csr_server_url_rejects_incomplete_urls(sgx_url): + with pytest.raises(sgx.SgxCertificateError): + sgx.csr_server_url(sgx_url) + + +def test_status_reports_missing_files(certs_dir): + status = sgx.get_certificate_status(certs_dir) + assert status['complete'] is False + assert status['present'] == {'key': False, 'csr': False, 'crt': False} + assert 'subject' not in status + assert sgx.get_certificate_status(certs_dir / 'absent')['complete'] is False + + +def test_status_describes_certificate(certs_dir, rpc): + wallet = FakeSgxWallet(rpc) + wallet.issue_files(certs_dir) + status = sgx.get_certificate_status(certs_dir) + assert status['complete'] is True + assert status['subject'] == 'ab' * 32 + assert status['issuer'] == 'sgx-wallet-ca' + assert status['key_matches'] is True + assert status['expired'] is False + assert status['not_yet_valid'] is False + assert status['expires_soon'] is False + assert 363 <= status['days_left'] <= 365 + assert status['fingerprint_sha256'] == wallet.last_issued.fingerprint(sgx.hashes.SHA256()).hex( + ':' + ) + json.dumps(status) + + +@pytest.mark.parametrize( + 'starts_in, ends_in, expired, not_yet_valid, expires_soon', + [ + (-1, 365, False, False, False), + (-2, -1, True, False, False), + (1, 365, False, True, False), + (-1, 1, False, False, True), + ], +) +def test_status_with_legacy_certificate_dates( + certs_dir, rpc, monkeypatch, starts_in, ends_in, expired, not_yet_valid, expires_soon +): + wallet = FakeSgxWallet(rpc) + wallet.issue_files(certs_dir) + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) + start = now + datetime.timedelta(days=starts_in) + end = now + datetime.timedelta(days=ends_in) + + class LegacyCertificate: + not_valid_before = start.replace(tzinfo=None) + not_valid_after = end.replace(tzinfo=None) + + def __getattr__(self, name): + if name in ('not_valid_before_utc', 'not_valid_after_utc'): + raise AttributeError(name) + return getattr(wallet.last_issued, name) + + monkeypatch.setattr(sgx, '_load_certificate', lambda _: LegacyCertificate()) + status = sgx.get_certificate_status(certs_dir) + assert status['not_valid_before'] == start.isoformat(timespec='seconds') + assert status['not_valid_after'] == end.isoformat(timespec='seconds') + assert status['expired'] is expired + assert status['not_yet_valid'] is not_yet_valid + assert status['expires_soon'] is expires_soon + assert status['key_matches'] is True + + +def test_status_detects_key_mismatch_and_partial_sets(certs_dir, rpc): + wallet = FakeSgxWallet(rpc) + wallet.issue_files(certs_dir) + other = certs_dir.parent / 'other' + wallet.issue_files(other) + (certs_dir / 'sgx.key').write_bytes((other / 'sgx.key').read_bytes()) + assert sgx.get_certificate_status(certs_dir)['key_matches'] is False + (certs_dir / 'sgx.key').write_text('not a key') + assert sgx.get_certificate_status(certs_dir)['key_matches'] is False + (certs_dir / 'sgx.key').unlink() + status = sgx.get_certificate_status(certs_dir) + assert status['key_matches'] is None + assert status['complete'] is False + + +def test_status_rejects_unreadable_certificate(certs_dir): + (certs_dir / 'sgx.crt').write_text('garbage') + with pytest.raises(sgx.SgxCertificateError, match='Cannot read certificate'): + sgx.get_certificate_status(certs_dir) + + +def test_check_certificate(certs_dir, rpc): + wallet = FakeSgxWallet(rpc) + with pytest.raises(sgx.SgxCertificateError, match='missing files: key, crt'): + sgx.check_certificate(SGX_URL, certs_dir) + wallet.issue_files(certs_dir) + assert sgx.check_certificate(SGX_URL, certs_dir) == '1.83.0' + assert wallet.client_certs == [(str(certs_dir / 'sgx.crt'), str(certs_dir / 'sgx.key'))] + + +def test_check_certificate_reports_rejection(certs_dir, rpc): + FakeSgxWallet(rpc, reject_clients=True).issue_files(certs_dir) + with pytest.raises(sgx.SgxCertificateError, match='rejected the TLS connection'): + sgx.check_certificate(SGX_URL, certs_dir) + + +def test_renew_installs_new_certificate_and_keeps_backup(certs_dir, rpc): + wallet = FakeSgxWallet(rpc) + wallet.issue_files(certs_dir) + before = snapshot(certs_dir) + backups = certs_dir.parent / 'sgx_certs_backup' + messages: list[str] = [] + + result = sgx.renew_certificate( + SGX_URL, directory=certs_dir, backup_root=backups, log=messages.append + ) + + assert names(certs_dir) == CERT_FILES + assert names(certs_dir.parent) == ['sgx_certs', 'sgx_certs_backup'] + after = snapshot(certs_dir) + assert all(after[name] != before[name] for name in CERT_FILES) + assert stat.S_IMODE((certs_dir / 'sgx.key').stat().st_mode) == 0o600 + assert stat.S_IMODE((certs_dir / 'sgx.crt').stat().st_mode) == 0o644 + assert stat.S_IMODE((certs_dir / 'sgx.csr').stat().st_mode) == 0o644 + + status = sgx.get_certificate_status(certs_dir) + assert status['complete'] and status['key_matches'] and status['issuer'] == 'sgx-wallet-ca' + assert status['fingerprint_sha256'] == wallet.last_issued.fingerprint(sgx.hashes.SHA256()).hex( + ':' + ) + assert result['server_version'] == '1.83.0' + assert result['fingerprint_sha256'] == status['fingerprint_sha256'] + + backup = Path(result['backup']) + assert backup.parent == backups + assert stat.S_IMODE(backups.stat().st_mode) == 0o700 + assert snapshot(backup) == before + + assert wallet.calls == [ + ('csr', 'signCertificate'), + ('csr', 'getCertificate'), + ('sgx', 'getServerVersion'), + ] + staged_crt, staged_key = wallet.client_certs[0] + assert Path(staged_crt).parent != certs_dir + assert Path(staged_crt).parent.name.startswith('.sgx_certs.') + assert not Path(staged_crt).exists() and not Path(staged_key).exists() + assert any('hash: ' in message for message in messages) + assert not any('Waiting' in message for message in messages) + + +def test_renew_waits_for_manual_approval(certs_dir, rpc): + wallet = FakeSgxWallet(rpc, pending_polls=2) + messages: list[str] = [] + sgx.renew_certificate(SGX_URL, directory=certs_dir, log=messages.append, timeout=30) + assert wallet.calls.count(('csr', 'getCertificate')) == 3 + assert sum('Waiting' in message for message in messages) == 1 + assert sgx.get_certificate_status(certs_dir)['complete'] + + +def test_renew_times_out_without_touching_current_files(certs_dir, rpc): + wallet = FakeSgxWallet(rpc, pending_polls=10**6) + wallet.issue_files(certs_dir) + before = snapshot(certs_dir) + backups = certs_dir.parent / 'sgx_certs_backup' + with pytest.raises(sgx.SgxCertificateError, match='did not sign the request within 0'): + sgx.renew_certificate(SGX_URL, directory=certs_dir, backup_root=backups, timeout=0) + assert snapshot(certs_dir) == before + assert names(certs_dir.parent) == ['sgx_certs'] + assert ('sgx', 'getServerVersion') not in wallet.calls + + +def test_renew_aborts_when_server_rejects_new_certificate(certs_dir, rpc): + wallet = FakeSgxWallet(rpc, reject_clients=True) + wallet.issue_files(certs_dir) + before = snapshot(certs_dir) + with pytest.raises(sgx.SgxCertificateError, match='rejected the TLS connection'): + sgx.renew_certificate(SGX_URL, directory=certs_dir) + assert snapshot(certs_dir) == before + assert names(certs_dir.parent) == ['sgx_certs'] + + +def test_renew_can_skip_server_verification(certs_dir, rpc): + wallet = FakeSgxWallet(rpc, reject_clients=True) + result = sgx.renew_certificate(SGX_URL, directory=certs_dir, verify=False) + assert result['server_version'] is None + assert result['backup'] is None + assert ('sgx', 'getServerVersion') not in wallet.calls + assert names(certs_dir) == CERT_FILES + assert names(certs_dir.parent) == ['sgx_certs'] + + +def test_renew_rejects_certificate_issued_for_another_key(certs_dir, rpc): + wallet = FakeSgxWallet(rpc, wrong_key=True) + wallet.issue_files(certs_dir) + before = snapshot(certs_dir) + with pytest.raises(sgx.SgxCertificateError, match='does not match the generated key'): + sgx.renew_certificate(SGX_URL, directory=certs_dir) + assert snapshot(certs_dir) == before + + +def test_renew_reports_signing_refusal(certs_dir, rpc): + wallet = FakeSgxWallet(rpc, sign_error='CSR rejected by policy') + wallet.issue_files(certs_dir) + before = snapshot(certs_dir) + with pytest.raises(sgx.SgxCertificateError, match='CSR rejected by policy'): + sgx.renew_certificate(SGX_URL, directory=certs_dir) + assert snapshot(certs_dir) == before + assert wallet.calls == [('csr', 'signCertificate')] + + +def test_renew_reports_transport_errors(certs_dir, rpc): + rpc.post('http://127.0.0.1:1027/', status_code=502) + with pytest.raises(sgx.SgxCertificateError, match='Cannot call signCertificate'): + sgx.renew_certificate(SGX_URL, directory=certs_dir) + assert names(certs_dir) == [] + assert names(certs_dir.parent) == ['sgx_certs'] + + +def test_renew_creates_missing_directory(tmp_path, rpc): + FakeSgxWallet(rpc) + directory = tmp_path / 'node_data' / 'sgx_certs' + result = sgx.renew_certificate(SGX_URL, directory=directory) + assert result['backup'] is None + assert names(directory) == CERT_FILES + + +def test_renew_reports_unwritable_directory(tmp_path, rpc): + FakeSgxWallet(rpc) + parent = tmp_path / 'node_data' + parent.mkdir(mode=0o500) + try: + with pytest.raises(sgx.SgxCertificateError, match='Cannot prepare certificate files'): + sgx.renew_certificate(SGX_URL, directory=parent / 'sgx_certs') + finally: + parent.chmod(0o700) diff --git a/tests/fixtures/sgx.py b/tests/fixtures/sgx.py new file mode 100644 index 00000000..14d26680 --- /dev/null +++ b/tests/fixtures/sgx.py @@ -0,0 +1,145 @@ +"""Test double for the SGX wallet ports used by the SGX certificate commands.""" + +import datetime +import hashlib +from pathlib import Path + +import requests +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +from node_cli.core import sgx + +SGX_URL = 'https://127.0.0.1:1026' +ONE_DAY = datetime.timedelta(days=1) + + +def _response(result: dict) -> dict: + return {'id': 0, 'jsonrpc': '2.0', 'result': result} + + +def _pem(obj) -> bytes: + return obj.public_bytes(serialization.Encoding.PEM) + + +def _fingerprint(cert: x509.Certificate) -> bytes: + return cert.fingerprint(hashes.SHA256()) + + +class FakeSgxWallet: + """Serves the CSR signing port and the main port of an SGX wallet through requests_mock. + + The main port emulates TLS client authentication: it only answers when the presented + certificate was issued by this wallet and matches the presented key. + """ + + def __init__( + self, + mock, + sgx_url: str = SGX_URL, + *, + pending_polls: int = 0, + sign_error: str | None = None, + reject_clients: bool = False, + wrong_key: bool = False, + version: str = '1.83.0', + ): + self.sgx_url = sgx_url + self.pending_polls = pending_polls + self.sign_error = sign_error + self.reject_clients = reject_clients + self.wrong_key = wrong_key + self.version = version + self.ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + self.ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'sgx-wallet-ca')]) + self.calls: list[tuple[str, str]] = [] + self.client_certs: list[tuple[str, str]] = [] + self.pending: dict[str, x509.CertificateSigningRequest] = {} + self.issued: list[x509.Certificate] = [] + mock.post(sgx_url.rstrip('/') + '/', json=self._main_port) + mock.post(sgx.csr_server_url(sgx_url) + '/', json=self._csr_port) + + @property + def last_issued(self) -> x509.Certificate: + return self.issued[-1] + + def issue_files(self, directory: Path) -> None: + """Write a valid key, request and certificate the way node services would have.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'ab' * 32)]) + csr = ( + x509.CertificateSigningRequestBuilder().subject_name(subject).sign(key, hashes.SHA256()) + ) + cert = self._sign(csr) + directory.mkdir(parents=True, exist_ok=True) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + (directory / 'sgx.key').write_bytes(key_pem) + (directory / 'sgx.csr').write_bytes(_pem(csr)) + (directory / 'sgx.crt').write_bytes(_pem(cert)) + + def _sign(self, csr: x509.CertificateSigningRequest, public_key=None) -> x509.Certificate: + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(csr.subject) + .issuer_name(self.ca_name) + .public_key(public_key or csr.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - ONE_DAY) + .not_valid_after(now + 365 * ONE_DAY) + .sign(self.ca_key, hashes.SHA256()) + ) + self.issued.append(cert) + return cert + + def _csr_port(self, request, context): + body = request.json() + method = body['method'] + self.calls.append(('csr', method)) + if method == 'signCertificate': + if self.sign_error: + return _response({'status': 1, 'errorMessage': self.sign_error}) + csr_pem = body['params']['certificate'] + csr = x509.load_pem_x509_csr(csr_pem.encode()) + assert csr.is_signature_valid + digest = hashlib.sha256(csr_pem.encode()).hexdigest() + self.pending[digest] = csr + return _response({'status': 0, 'hash': digest}) + if method == 'getCertificate': + csr = self.pending[body['params']['hash']] + if self.pending_polls > 0: + self.pending_polls -= 1 + return _response( + {'status': 1, 'cert': '', 'errorMessage': 'Certificate is not signed yet'} + ) + public_key = None + if self.wrong_key: + public_key = rsa.generate_private_key(65537, 2048).public_key() + cert = self._sign(csr, public_key) + return _response({'status': 0, 'cert': _pem(cert).decode()}) + raise AssertionError(f'Unexpected method on the CSR port: {method}') + + def _main_port(self, request, context): + body = request.json() + self.calls.append(('sgx', body['method'])) + assert request.verify is False + self.client_certs.append(request.cert) + crt_path, key_path = request.cert + cert = x509.load_pem_x509_certificate(Path(crt_path).read_bytes()) + key = serialization.load_pem_private_key(Path(key_path).read_bytes(), password=None) + known = {_fingerprint(issued) for issued in self.issued} + if ( + self.reject_clients + or _fingerprint(cert) not in known + or key.public_key().public_numbers() != cert.public_key().public_numbers() + ): + raise requests.exceptions.SSLError('tlsv1 alert unknown ca') + if body['method'] == 'getServerVersion': + return _response({'status': 0, 'version': self.version}) + raise AssertionError(f'Unexpected method on the main port: {body["method"]}') diff --git a/tests/routes_test.py b/tests/routes_test.py index 9a346748..51b9c5d8 100644 --- a/tests/routes_test.py +++ b/tests/routes_test.py @@ -21,6 +21,7 @@ '/api/v1/health/containers', '/api/v1/health/schains', '/api/v1/info/sgx', + '/api/v1/info/sgx-options', '/api/v1/schains/config', '/api/v1/schains/list', '/api/v1/schains/dkg-statuses', diff --git a/tests/utils/api_auth_test.py b/tests/utils/api_auth_test.py new file mode 100644 index 00000000..0549d5b6 --- /dev/null +++ b/tests/utils/api_auth_test.py @@ -0,0 +1,163 @@ +import os +import pwd +import stat +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import Mock + +import pytest +import requests_mock + +from node_cli.utils import api_auth, helper + + +@pytest.fixture +def token_path(tmp_path, monkeypatch): + path = tmp_path / '.skale' / 'auth' / 'admin-api.token' + monkeypatch.setattr(api_auth, 'ADMIN_API_TOKEN_PATH', path) + monkeypatch.setattr(api_auth, 'G_CONF_USER', pwd.getpwuid(os.geteuid()).pw_name) + return path + + +def test_provisioning_preserves_token_and_restricts_permissions(token_path): + api_auth.ensure_api_token() + original = token_path.read_bytes() + assert len(api_auth.read_api_token()) == 64 + assert stat.S_IMODE(token_path.stat().st_mode) == 0o600 + assert token_path.stat().st_uid == os.geteuid() + assert stat.S_IMODE(token_path.parent.stat().st_mode) == 0o700 + assert token_path.parent.stat().st_uid == os.geteuid() + api_auth.ensure_api_token() + assert token_path.read_bytes() == original + assert list(token_path.parent.iterdir()) == [token_path] + + +def test_existing_auth_directory_is_secured(token_path): + api_auth.ensure_api_token() + original = token_path.read_bytes() + token_path.parent.chmod(0o755) + api_auth.ensure_api_token() + assert stat.S_IMODE(token_path.parent.stat().st_mode) == 0o700 + assert token_path.read_bytes() == original + + +def test_auth_directory_cannot_point_back_to_node_data(token_path): + node_data = token_path.parent.parent / 'node_data' + node_data.mkdir(parents=True) + token_path.parent.symlink_to(node_data, target_is_directory=True) + with pytest.raises(api_auth.APIAuthError): + api_auth.ensure_api_token() + assert list(node_data.iterdir()) == [] + + +def test_concurrent_provisioning_publishes_one_complete_token(token_path): + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(lambda _: api_auth.ensure_api_token(), range(16))) + assert len(api_auth.read_api_token()) == 64 + assert list(token_path.parent.iterdir()) == [token_path] + + +@pytest.mark.parametrize('bad_file', ['empty', 'oversized', 'non_ascii', 'permissions', 'symlink']) +def test_invalid_credentials_are_not_replaced(token_path, bad_file): + api_auth.ensure_api_token() + if bad_file == 'permissions': + token_path.chmod(0o644) + elif bad_file == 'symlink': + token_path.unlink() + token_path.symlink_to(token_path.with_suffix('.missing')) + else: + token_path.write_text( + {'empty': '', 'oversized': 'ab' * 32 + '\n\nmore', 'non_ascii': 'é'}[bad_file] + ) + with pytest.raises(api_auth.APIAuthError): + api_auth.ensure_api_token() + + +def test_missing_token_supports_upgrade_from_old_api(token_path): + assert api_auth.get_api_headers() == {} + with requests_mock.Mocker() as mock: + mock.get( + helper.construct_url('/api/v1/node/update-safe'), json={'status': 'ok', 'payload': {}} + ) + assert helper.get_request('node', 'update-safe') == ('ok', {}) + assert 'Authorization' not in mock.last_request.headers + assert not token_path.exists() + + +def test_headers_sent_for_get_post_and_upload(token_path): + api_auth.ensure_api_token() + expected = f'Bearer {api_auth.read_api_token()}' + with requests_mock.Mocker() as mock: + mock.get( + helper.construct_url('/api/v1/node/signature'), json={'status': 'ok', 'payload': {}} + ) + mock.post( + helper.construct_url('/api/v1/wallet/send-eth'), json={'status': 'ok', 'payload': {}} + ) + mock.post(helper.construct_url('/api/v1/ssl/upload'), json={'status': 'ok', 'payload': {}}) + assert helper.get_request('node', 'signature') == ('ok', {}) + assert helper.post_request('wallet', 'send-eth', json={'amount': 1}) == ('ok', {}) + assert helper.post_request('ssl', 'upload', files={'ssl_cert': ('cert', b'cert')}) == ( + 'ok', + {}, + ) + assert all(req.headers['Authorization'] == expected for req in mock.request_history) + assert 'multipart/form-data' in mock.last_request.headers['Content-Type'] + + +def test_http_errors_reach_cli(token_path): + api_auth.ensure_api_token() + with requests_mock.Mocker() as mock: + mock.post( + helper.construct_url('/api/v1/wallet/send-eth'), + status_code=401, + json={'status': 'error', 'payload': 'A valid node CLI credential is required'}, + ) + assert helper.post_request('wallet', 'send-eth') == ( + 'error', + 'A valid node CLI credential is required', + ) + + +def test_does_not_follow_redirects_or_use_proxy_credentials(token_path, monkeypatch): + api_auth.ensure_api_token() + monkeypatch.setenv('HTTP_PROXY', 'http://proxy.invalid:8080') + netrc = Mock(side_effect=AssertionError('Must not read netrc')) + monkeypatch.setattr('requests.sessions.get_netrc_auth', netrc) + with requests_mock.Mocker() as mock: + mock.get( + helper.construct_url('/api/v1/node/signature'), + status_code=302, + headers={'Location': 'http://other.invalid/steal'}, + json={'status': 'error', 'payload': 'Redirect'}, + ) + assert helper.get_request('node', 'signature') == ('error', 'Redirect') + assert len(mock.request_history) == 1 + assert mock.last_request.proxies == {} + netrc.assert_not_called() + + +def test_provisions_before_compose_starts(token_path, monkeypatch): + from node_cli.utils import docker_utils + from node_cli.utils.node_type import NodeMode, NodeType + + def run(cmd, env): + assert api_auth.read_api_token() is not None + + start = Mock(side_effect=run) + monkeypatch.setattr(docker_utils, 'run_cmd', start) + settings = Mock(tg_api_key=None) + docker_utils.compose_up({}, settings, NodeType.SKALE, NodeMode.ACTIVE) + start.assert_called_once() + + +def test_invalid_token_prevents_starting_services(token_path, monkeypatch): + from node_cli.utils import docker_utils + from node_cli.utils.node_type import NodeMode, NodeType + + api_auth.ensure_api_token() + token_path.chmod(0o644) + start = Mock() + monkeypatch.setattr(docker_utils, 'run_cmd', start) + with pytest.raises(api_auth.APIAuthError): + docker_utils.compose_up({}, Mock(), NodeType.SKALE, NodeMode.ACTIVE) + start.assert_not_called() diff --git a/text.yml b/text.yml index 6013f2d0..652adf7e 100644 --- a/text.yml +++ b/text.yml @@ -102,3 +102,27 @@ fair: exit: help: Remove node from Fair manager prompt: Are you sure you want to remove the node from Fair manager? + +sgx: + help: SGX wallet options and client certificate commands + no_sgx: This node has no SGX server configured (passive nodes do not use SGX) + options: + help: Show SGX server options through the node API + status: + help: Show the SGX client certificate used by node services + check: Also verify that the SGX server accepts the certificate + missing: |- + SGX client certificate files are missing. + Node services create them on their first SGX request, or run < skale sgx renew > + expired: SGX client certificate has expired. Run < skale sgx renew > + expires_soon: "SGX client certificate expires in {days} days. Consider running < skale sgx renew >" + key_mismatch: Private key does not match the certificate. Run < skale sgx renew > + renew: + help: Issue a new SGX client certificate and install it for node services + prompt: Are you sure you want to replace the SGX client certificate of this node? + timeout: Seconds to wait for the SGX server to sign the request + skip_verify: Install the certificate without testing it against the SGX server first + backup: "Previous certificate files were copied to {path}" + done: |- + New SGX client certificate installed. + Node services use it on their next SGX request; no restart is needed.