diff --git a/cli/cenclave/src/cenclave/__init__.py b/cli/cenclave/src/cenclave/__init__.py index 054c40d..c5ce567 100644 --- a/cli/cenclave/src/cenclave/__init__.py +++ b/cli/cenclave/src/cenclave/__init__.py @@ -2,7 +2,7 @@ import os -__version__ = "1.0.0a6" +__version__ = "1.0.0a11" # PCCS to retrieve collaterals PCCS_URL = os.getenv("PCCS_URL", default="https://pccs.cosmian.com") diff --git a/cli/cenclave/src/cenclave/command/sgx_operator/spawn.py b/cli/cenclave/src/cenclave/command/sgx_operator/spawn.py index 2b1736b..70e6a40 100644 --- a/cli/cenclave/src/cenclave/command/sgx_operator/spawn.py +++ b/cli/cenclave/src/cenclave/command/sgx_operator/spawn.py @@ -92,6 +92,20 @@ def add_subparser(subparsers): help="enclave size to spawn (must be a power of 2)", ) + parser.add_argument( + "--client-certificate", + type=Path, + help="bundle for client certificate authentication", + ) + + parser.add_argument( + "--ssl-verify-mode", + type=int, + help="Either CERT_OPTIONAL (1) or CERT_REQUIRED (2). Default to CERT_REQUIRED.", + choices=[1, 2], + default=2, + ) + parser.add_argument( "--signer-key", type=Path, @@ -157,6 +171,10 @@ def run(args) -> None: subject_alternative_name=args.san, app_id=uuid4(), expiration_date=int((datetime.today() + timedelta(days=args.days)).timestamp()), + client_certificate=( + args.client_certificate.read_text() if args.client_certificate else None + ), + ssl_verify_mode=(args.ssl_verify_mode if args.client_certificate else None), app_dir=workspace, application=code_config.python_application, healthcheck=code_config.healthcheck_endpoint, diff --git a/cli/cenclave/src/cenclave/core/bootstrap.py b/cli/cenclave/src/cenclave/core/bootstrap.py index 69eb2d3..e79809d 100644 --- a/cli/cenclave/src/cenclave/core/bootstrap.py +++ b/cli/cenclave/src/cenclave/core/bootstrap.py @@ -4,6 +4,7 @@ from uuid import UUID import requests +import urllib3 from pydantic import BaseModel from cenclave.core.base64 import base64url_encode @@ -122,11 +123,15 @@ def is_ready( if response.status_code != 503 and "Mse-Status" not in response.headers: return True - except requests.exceptions.Timeout: - return False - except requests.exceptions.SSLError: + except ( + requests.exceptions.Timeout, + requests.exceptions.SSLError, + ): return False - except requests.exceptions.ConnectionError: + except requests.exceptions.ConnectionError as exc: + err, *_ = exc.args + if isinstance(err, urllib3.exceptions.ProtocolError): + return True return False return False diff --git a/cli/cenclave/src/cenclave/core/no_sgx_docker.py b/cli/cenclave/src/cenclave/core/no_sgx_docker.py index e55ba1d..e3f52f0 100644 --- a/cli/cenclave/src/cenclave/core/no_sgx_docker.py +++ b/cli/cenclave/src/cenclave/core/no_sgx_docker.py @@ -15,6 +15,8 @@ class NoSgxDockerConfig(BaseModel): subject: str subject_alternative_name: str expiration_date: Optional[int] + client_certificate: Optional[str] + ssl_verify_mode: Optional[int] size: int app_id: UUID application: str @@ -24,25 +26,37 @@ class NoSgxDockerConfig(BaseModel): def cmd(self) -> List[str]: """Serialize the docker command args.""" - command = [ + args = [ + "--application", + self.application, "--size", f"{self.size}M", - "--subject", - self.subject, "--san", self.subject_alternative_name, "--id", str(self.app_id), - "--application", - self.application, - "--dry-run", + "--subject", + self.subject, ] if self.expiration_date: - command.append("--expiration") - command.append(str(self.expiration_date)) + args.append("--expiration") + args.append(str(self.expiration_date)) + + if client_certificate := self.client_certificate: + if ssl_verify_mode := self.ssl_verify_mode: + args.extend( + [ + "--client-certificate", + client_certificate, + "--ssl-verify-mode", + str(ssl_verify_mode), + ] + ) + + args.append("--dry-run") - return command + return args def volumes(self, app_path: Path) -> Dict[str, Dict[str, str]]: """Define the docker volumes.""" @@ -60,6 +74,8 @@ def from_sgx(docker_config: SgxDockerConfig): subject=docker_config.subject, subject_alternative_name=docker_config.subject_alternative_name, expiration_date=docker_config.expiration_date, + client_certificate=docker_config.client_certificate, + ssl_verify_mode=docker_config.ssl_verify_mode, size=docker_config.size, app_id=docker_config.app_id, application=docker_config.application, diff --git a/cli/cenclave/src/cenclave/core/sgx_docker.py b/cli/cenclave/src/cenclave/core/sgx_docker.py index aec8cbb..f711f57 100644 --- a/cli/cenclave/src/cenclave/core/sgx_docker.py +++ b/cli/cenclave/src/cenclave/core/sgx_docker.py @@ -1,10 +1,10 @@ """cenclave.core.sgx_docker module.""" from pathlib import Path -from typing import Any, ClassVar, Dict, List, Tuple +from typing import Any, ClassVar, Dict, List, Optional, Tuple from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, validator class SgxDockerConfig(BaseModel): @@ -17,6 +17,8 @@ class SgxDockerConfig(BaseModel): subject: str subject_alternative_name: str expiration_date: int + client_certificate: Optional[str] + ssl_verify_mode: Optional[int] app_dir: Path application: str healthcheck: str @@ -27,23 +29,50 @@ class SgxDockerConfig(BaseModel): docker_label: ClassVar[str] = "cenclave" entrypoint: ClassVar[str] = "cenclave-run" + # pylint: disable=no-self-argument + @validator("ssl_verify_mode") + def check_ssl_verify_mode(cls, v, values): + """Validate ssl_verify_mode with client_certificate.""" + if "ssl_verify_mode" in values and not values["client_certificate"]: + raise ValueError("no client_certificate with ssl_verify_mode") + + if v and v not in (1, 2): + raise ValueError( + "ssl_verify_mode must be 1 (CERT_OPTIONAL) or 2 (CERT_REQUIRED)" + ) + + return v + def cmd(self) -> List[str]: """Serialize the docker command args.""" - return [ + args = [ + "--application", + self.application, "--size", f"{self.size}M", - "--subject", - self.subject, "--san", self.subject_alternative_name, "--id", str(self.app_id), - "--application", - self.application, + "--subject", + self.subject, "--expiration", str(self.expiration_date), ] + if client_certificate := self.client_certificate: + if ssl_verify_mode := self.ssl_verify_mode: + args.extend( + [ + "--client-certificate", + client_certificate, + "--ssl-verify-mode", + str(ssl_verify_mode), + ] + ) + + return args + def ports(self) -> Dict[str, Tuple[str, str]]: """Define the docker ports.""" return {"443/tcp": (self.host, str(self.port))} @@ -123,6 +152,8 @@ def load(docker_attrs: Dict[str, Any], docker_labels: Any): subject_alternative_name=data_map["san"], app_id=UUID(data_map["id"]), expiration_date=int(data_map["expiration"]), + client_certificate=data_map.get("client-certificate"), + ssl_verify_mode=data_map.get("ssl-verify-mode"), app_dir=Path(app["Source"]), application=data_map["application"], port=int(port["443/tcp"][0]["HostPort"]), diff --git a/cli/cenclave/src/cenclave/model/evidence.py b/cli/cenclave/src/cenclave/model/evidence.py index 4a9fa6f..933d27a 100644 --- a/cli/cenclave/src/cenclave/model/evidence.py +++ b/cli/cenclave/src/cenclave/model/evidence.py @@ -91,6 +91,8 @@ def save(self, path: Path) -> None: if self.input_args.expiration_date else None ), + "client_certificate": self.input_args.client_certificate, + "ssl_verify_mode": self.input_args.ssl_verify_mode, "size": self.input_args.size, "app_id": str(self.input_args.app_id), "application": self.input_args.application, diff --git a/cli/cenclave/tests/data/evidence.json b/cli/cenclave/tests/data/evidence.json index 1c16fe8..abbb4e4 100644 --- a/cli/cenclave/tests/data/evidence.json +++ b/cli/cenclave/tests/data/evidence.json @@ -3,6 +3,8 @@ "subject": "CN=localhost,O=MyApp Company,C=FR,L=Paris,ST=Ile-de-France", "subject_alternative_name": "localhost", "expiration_date": 1714058115, + "client_certificate": null, + "ssl_verify_mode": null, "size": 4096, "app_id": "63322f85-1ff8-4483-91ae-f18d7398d157", "application": "app:app" diff --git a/cli/cenclave/tests/test_no_sgx_docker.py b/cli/cenclave/tests/test_no_sgx_docker.py index 7d5ba96..ac59dde 100644 --- a/cli/cenclave/tests/test_no_sgx_docker.py +++ b/cli/cenclave/tests/test_no_sgx_docker.py @@ -12,6 +12,8 @@ def test_from_sgx(): subject="CN=localhost,O=Big Company,C=FR,L=Paris,ST=Ile-de-France", subject_alternative_name="localhost", expiration_date=1714058115, + client_certificate=None, + ssl_verify_mode=None, size=4096, app_id="63322f85-1ff8-4483-91ae-f18d7398d157", application="app:app", @@ -26,6 +28,8 @@ def test_from_sgx(): subject_alternative_name="localhost", app_id="63322f85-1ff8-4483-91ae-f18d7398d157", expiration_date=1714058115, + client_certificate=None, + ssl_verify_mode=None, app_dir="/home/cosmian/workspace/sgx_operator/", application="app:app", healthcheck="/health", @@ -42,6 +46,8 @@ def test_volumes(): subject="CN=localhost,O=Big Company,C=FR,L=Paris,ST=Ile-de-France", subject_alternative_name="localhost", expiration_date=1714058115, + client_certificate=None, + ssl_verify_mode=None, size=4096, app_id="63322f85-1ff8-4483-91ae-f18d7398d157", application="app:app", @@ -61,45 +67,49 @@ def test_cmd(): subject="CN=localhost,O=Big Company,C=FR,L=Paris,ST=Ile-de-France", subject_alternative_name="localhost", expiration_date=1714058115, + client_certificate=None, + ssl_verify_mode=None, size=4096, app_id="63322f85-1ff8-4483-91ae-f18d7398d157", application="app:app", ) assert ref_conf.cmd() == [ + "--application", + "app:app", "--size", "4096M", - "--subject", - "CN=localhost,O=Big Company,C=FR,L=Paris,ST=Ile-de-France", "--san", "localhost", "--id", "63322f85-1ff8-4483-91ae-f18d7398d157", - "--application", - "app:app", - "--dry-run", + "--subject", + "CN=localhost,O=Big Company,C=FR,L=Paris,ST=Ile-de-France", "--expiration", "1714058115", + "--dry-run", ] ref_conf = NoSgxDockerConfig( subject="CN=localhost,O=Big Company,C=FR,L=Paris,ST=Ile-de-France", subject_alternative_name="localhost", + client_certificate=None, + ssl_verify_mode=None, size=4096, app_id="63322f85-1ff8-4483-91ae-f18d7398d157", application="app:app", ) assert ref_conf.cmd() == [ + "--application", + "app:app", "--size", "4096M", - "--subject", - "CN=localhost,O=Big Company,C=FR,L=Paris,ST=Ile-de-France", "--san", "localhost", "--id", "63322f85-1ff8-4483-91ae-f18d7398d157", - "--application", - "app:app", + "--subject", + "CN=localhost,O=Big Company,C=FR,L=Paris,ST=Ile-de-France", "--dry-run", ] diff --git a/cli/cenclave/tests/test_sgx_docker.py b/cli/cenclave/tests/test_sgx_docker.py index e4b9e29..1863849 100644 --- a/cli/cenclave/tests/test_sgx_docker.py +++ b/cli/cenclave/tests/test_sgx_docker.py @@ -1,6 +1,5 @@ """Test model/docker.py.""" - from cenclave.core.sgx_docker import SgxDockerConfig @@ -14,6 +13,8 @@ def test_load(): subject_alternative_name="myapp.fr", app_id="4141a3e6-1f2b-4ccf-8610-aa0891a1a210", expiration_date=1714639412, + client_certificate=None, + ssl_verify_mode=None, app_dir="/home/cosmian/workspace/sgx_operator/", application="app:app", healthcheck="/health", @@ -86,6 +87,8 @@ def test_labels(): subject_alternative_name="myapp.fr", app_id="4141a3e6-1f2b-4ccf-8610-aa0891a1a210", expiration_date=1714639412, + client_certificate=None, + ssl_verify_mode=None, app_dir="/home/cosmian/workspace/sgx_operator/", application="app:app", healthcheck="/health", @@ -105,6 +108,8 @@ def test_devices(): subject_alternative_name="myapp.fr", app_id="4141a3e6-1f2b-4ccf-8610-aa0891a1a210", expiration_date=1714639412, + client_certificate=None, + ssl_verify_mode=None, app_dir="/home/cosmian/workspace/sgx_operator/", application="app:app", healthcheck="/health", @@ -129,6 +134,8 @@ def test_ports(): subject_alternative_name="myapp.fr", app_id="4141a3e6-1f2b-4ccf-8610-aa0891a1a210", expiration_date=1714639412, + client_certificate=None, + ssl_verify_mode=None, app_dir="/home/cosmian/workspace/sgx_operator/code.tar", application="app:app", healthcheck="/health", @@ -148,6 +155,8 @@ def test_volumes(): subject_alternative_name="myapp.fr", app_id="4141a3e6-1f2b-4ccf-8610-aa0891a1a210", expiration_date=1714639412, + client_certificate=None, + ssl_verify_mode=None, app_dir="/home/cosmian/workspace/sgx_operator/", application="app:app", healthcheck="/health", @@ -180,6 +189,8 @@ def test_cmd(): subject_alternative_name="myapp.fr", app_id="4141a3e6-1f2b-4ccf-8610-aa0891a1a210", expiration_date=1714639412, + client_certificate=None, + ssl_verify_mode=None, app_dir="/home/cosmian/workspace/sgx_operator/", application="app:app", healthcheck="/health", @@ -187,16 +198,16 @@ def test_cmd(): ) assert ref_conf.cmd() == [ + "--application", + "app:app", "--size", "4096M", - "--subject", - "CN=myapp.fr,O=MyApp Company,C=FR,L=Paris,ST=Ile-de-France", "--san", "myapp.fr", "--id", "4141a3e6-1f2b-4ccf-8610-aa0891a1a210", - "--application", - "app:app", + "--subject", + "CN=myapp.fr,O=MyApp Company,C=FR,L=Paris,ST=Ile-de-France", "--expiration", "1714639412", ] diff --git a/examples/yaos_millionaires/Dockerfile b/examples/yaos_millionaires/Dockerfile index 543a78d..1e50f20 100644 --- a/examples/yaos_millionaires/Dockerfile +++ b/examples/yaos_millionaires/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/cosmian/cenclave-base:20241213084448 +FROM ghcr.io/cosmian/cenclave-base-beta:764d2b9114072facd659383539673018a51f55de RUN . /opt/venv/bin/activate && \ - pip3 install "flask==3.1.0" +pip3 install "fastapi>=0.115.6,<0.116" \ No newline at end of file diff --git a/examples/yaos_millionaires/README.md b/examples/yaos_millionaires/README.md index 09d7998..03be8d2 100644 --- a/examples/yaos_millionaires/README.md +++ b/examples/yaos_millionaires/README.md @@ -38,7 +38,7 @@ then populate `src/config.json` with participant's public key base64-encoded: $ cenclave localtest --code src/ \ --dockerfile Dockerfile \ --config config.toml \ - --test tests/ + --test tests/ \ --simu-enclave-keypair tests/data/keypair_enclave.bin ``` diff --git a/examples/yaos_millionaires/client/main.py b/examples/yaos_millionaires/client/main.py index 859c11d..95b922e 100644 --- a/examples/yaos_millionaires/client/main.py +++ b/examples/yaos_millionaires/client/main.py @@ -57,10 +57,10 @@ def push( encrypted_n: bytes = seal(encoded_n, enclave_pk) response: requests.Response = session.post( - url=url, + url=f"{url}/push", json={ "pk": base64.b64encode(pk).decode("utf-8"), - "data": {"n": base64.b64encode(encrypted_n).decode("utf-8")}, + "data": base64.b64encode(encrypted_n).decode("utf-8"), }, ) diff --git a/examples/yaos_millionaires/src/app.py b/examples/yaos_millionaires/src/app.py index 88ef250..6e7cec1 100644 --- a/examples/yaos_millionaires/src/app.py +++ b/examples/yaos_millionaires/src/app.py @@ -6,103 +6,103 @@ import struct from http import HTTPStatus from pathlib import Path -from typing import Any, Optional +from typing import List, Optional from cenclave_lib_crypto.seal_box import seal, unseal -from flask import Flask, Response, jsonify, request +from cryptography import x509 +from fastapi import FastAPI, Request, Response +from pydantic import BaseModel import globs -app = Flask(__name__) +app = FastAPI() CONFIG = json.loads((Path(__file__).parent / "config.json").read_text(encoding="utf-8")) ENCLAVE_SK: bytes = Path(os.environ["ENCLAVE_SK_PATH"]).read_bytes() -@app.get("/health") -def health_check() -> Response: - """Health check of the application.""" - return Response(response="OK", status=HTTPStatus.OK) +class PushItemReq(BaseModel): + """Item request from /push endpoint.""" + pk: str + data: str -@app.post("/") -def push() -> Response: - """Add a number to the pool.""" - content: Optional[Any] = request.get_json(silent=True) - if content is None or not isinstance(content, dict): - app.logger.error("TypeError with data: '%s'", content) - return Response(status=HTTPStatus.UNPROCESSABLE_ENTITY) +class RichestItemReq(BaseModel): + """Item request from /richest endpoint.""" + + recipient_pk: str + + +class ParticipantsItemResp(BaseModel): + """Item response from /participants endpoint.""" + + participants: List[str] + + +class RichestItemResp(BaseModel): + """Item response from /richest endpoint.""" + + max: Optional[str] - data: Optional[Any] = content.get("data") - pk: Optional[str] = content.get("pk") - if data is None or not isinstance(data, dict): - app.logger.error("TypeError with data content: '%s' (%s)", data, type(data)) - return Response(status=HTTPStatus.UNPROCESSABLE_ENTITY) +@app.get("/health") +async def health_check() -> Response: + """Health check of the application.""" + return Response(status_code=HTTPStatus.OK) + - if pk is None or not isinstance(pk, str): - app.logger.error("TypeError with data content: '%s' (%s)", pk, type(pk)) - return Response(status=HTTPStatus.UNPROCESSABLE_ENTITY) +@app.get("/who") +async def who(request: Request) -> Response: + """Check CN of client certificate if any.""" + if "tls" in request.scope["extensions"]: + if "client_cert_chain" in request.scope["extensions"]["tls"]: + client_cert, *_ = request.scope["extensions"]["tls"]["client_cert_chain"] + cert = x509.load_pem_x509_certificate(client_cert.encode("utf-8")) + name_attr, *_ = cert.subject.get_attributes_for_oid( + x509.NameOID.COMMON_NAME + ) + cn = name_attr.value + return Response( + content=f"Hello {cn}".encode("utf-8"), status_code=HTTPStatus.OK + ) - if pk not in CONFIG["participants"]: - app.logger.error( - "The public key provided is not in the participants: '%s' (%s)", - pk, - CONFIG["participants"], - ) - return Response(status=HTTPStatus.UNPROCESSABLE_ENTITY) + return Response(status_code=HTTPStatus.UNAUTHORIZED) - if pk in dict(globs.POOL): - app.logger.error("Public key already pushed data") - return Response(status=HTTPStatus.CONFLICT) - n: bytes = unseal(base64.b64decode(data["n"]), ENCLAVE_SK) +@app.post("/push") +def push(item: PushItemReq) -> Response: + """Add a number to the pool.""" + if item.pk not in CONFIG["participants"]: + return Response(status_code=HTTPStatus.UNAUTHORIZED) + + if item.pk in dict(globs.POOL): + return Response(status_code=HTTPStatus.CONFLICT) + n: bytes = unseal(base64.b64decode(item.data), ENCLAVE_SK) deser_n, *_ = struct.unpack(" Response: +def participants(): """Get all the public keys of participants""" - return jsonify(CONFIG) + return ParticipantsItemResp(participants=CONFIG["participants"]) @app.post("/richest") -def richest(): - """Get the current max in pool.""" +def richest(item: RichestItemReq): + """Get the richest in pool.""" if len(globs.POOL) < 1: - app.logger.error("need more than 1 value to compute the max") - return {"max": None} + return RichestItemResp(max=None) - data: Optional[Any] = request.get_json(silent=True) + if item.recipient_pk not in CONFIG["participants"]: + return Response(status_code=HTTPStatus.UNPROCESSABLE_ENTITY) - if data is None or not isinstance(data, dict): - app.logger.error("TypeError with data: '%s'", data) - return Response(status=HTTPStatus.UNPROCESSABLE_ENTITY) - - recipient_pk: Optional[str] = data.get("recipient_pk") - - if recipient_pk is None or not isinstance(recipient_pk, str): - app.logger.error( - "TypeError with data content: '%s' (%s)", recipient_pk, type(recipient_pk) - ) - return Response(status=HTTPStatus.UNPROCESSABLE_ENTITY) - - if recipient_pk not in CONFIG["participants"]: - app.logger.error( - "The public key provided is not in the participants: '%s' (%s)", - recipient_pk, - CONFIG["participants"], - ) - return Response(status=HTTPStatus.UNPROCESSABLE_ENTITY) - - raw_recipient_pk: bytes = base64.b64decode(recipient_pk) + raw_recipient_pk: bytes = base64.b64decode(item.recipient_pk) (pk, _) = max(globs.POOL, key=lambda t: t[1]) @@ -110,14 +110,12 @@ def richest(): seal(base64.b64decode(pk), raw_recipient_pk) ).decode("utf-8") - return jsonify({"max": encrypted_b64_result}) + return RichestItemResp(max=encrypted_b64_result) @app.delete("/") -def reset(): +def reset() -> Response: """Reset the current pool.""" globs.POOL = [] - app.logger.info("Reset successfully") - - return Response(status=HTTPStatus.OK) + return Response(status_code=HTTPStatus.OK) diff --git a/examples/yaos_millionaires/tests/test_app.py b/examples/yaos_millionaires/tests/test_app.py index 4287d6f..810a0a0 100644 --- a/examples/yaos_millionaires/tests/test_app.py +++ b/examples/yaos_millionaires/tests/test_app.py @@ -32,7 +32,7 @@ def test_participants(url, session, pk1, pk1_b64, pk2, pk2_b64): def test_richest(url, session, pk1_b64, sk1, pk2_b64, sk2, pk_enclave): # reset first - response = session.delete(f"{url}", timeout=10) + response = session.delete(url, timeout=10) assert response.status_code == 200 n: float = 97.0 @@ -40,10 +40,10 @@ def test_richest(url, session, pk1_b64, sk1, pk2_b64, sk2, pk_enclave): encrypted_n: bytes = seal(encoded_n, pk_enclave) response = session.post( - url, + f"{url}/push", json={ "pk": pk1_b64.decode("utf-8"), - "data": {"n": base64.b64encode(encrypted_n).decode("utf-8")}, + "data": base64.b64encode(encrypted_n).decode("utf-8"), }, timeout=10, ) @@ -55,10 +55,10 @@ def test_richest(url, session, pk1_b64, sk1, pk2_b64, sk2, pk_enclave): encrypted_n: bytes = seal(encoded_n, pk_enclave) response = session.post( - url, + f"{url}/push", json={ "pk": pk2_b64.decode("utf-8"), - "data": {"n": base64.b64encode(encrypted_n).decode("utf-8")}, + "data": base64.b64encode(encrypted_n).decode("utf-8"), }, timeout=10, )