diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 9a0c4477..35af9a15 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.7.3 +current_version = 1.10.0 commit = False tag = False diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 3be369f8..9befe3ac 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -10,6 +10,8 @@ else echo "No UI found at /ui_build, skipping copy." fi +export TAKRMAPI_SECRET_KEY=${RMAPI_SECRET_KEY:-$(dd if=/dev/random bs=32 count=1 | base64 2>/dev/null)} + if [ "$#" -eq 0 ]; then exec gunicorn "takrmapi.app:get_app()" --bind 0.0.0.0:8003 --forwarded-allow-ips='*' -w 4 -k uvicorn.workers.UvicornWorker else diff --git a/pyproject.toml b/pyproject.toml index 6a6cbafd..086761ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "takrmapi" -version = "1.7.3" +version = "1.10.0" description = "RASENMAEHER integration API for TAK server" authors = ["Eero af Heurlin ", "Ari Karhunen "] homepage = "https://github.com/pvarki/python-tak-rmapi" diff --git a/src/takrmapi/__init__.py b/src/takrmapi/__init__.py index adb9fac0..f1151c9e 100644 --- a/src/takrmapi/__init__.py +++ b/src/takrmapi/__init__.py @@ -1,3 +1,3 @@ """RASENMAEHER integration API for TAK server""" -__version__ = "1.7.3" # NOTE Use `bump2version --config-file patch` to bump versions correctly +__version__ = "1.10.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly diff --git a/src/takrmapi/api/__init__.py b/src/takrmapi/api/__init__.py index c2352058..98a891ab 100644 --- a/src/takrmapi/api/__init__.py +++ b/src/takrmapi/api/__init__.py @@ -10,11 +10,14 @@ from .description import router as description_router from .instructions import router as instructions_router from .tak_datapackage import router as takdatapackage_router +from .tak_missionpackage import router as takmissionpackage_router from .description import router_v2 as description_router_v2 from .description import router_v2_admin as description_admin_router from .userinfo import router as userinfo_router +from .tak_missionpackage import ephemeral_router as ephemeral_takmissionpackage_router + all_routers = APIRouter() all_routers.include_router(testing_router, prefix="/users", tags=["users"]) # REMOVE ME all_routers.include_router(usercrud_router, prefix="/users", tags=["users"]) @@ -24,8 +27,16 @@ all_routers.include_router(description_router, prefix="/description", tags=["description"]) all_routers.include_router(instructions_router, prefix="/instructions", tags=["instructions"]) all_routers.include_router(takdatapackage_router, prefix="/tak-datapackages", tags=["tak-datapackages"]) +all_routers.include_router(takmissionpackage_router, prefix="/tak-missionpackages", tags=["tak-missionpackages"]) + all_routers_v2 = APIRouter() all_routers_v2.include_router(description_router_v2, prefix="/description", tags=["description"]) all_routers_v2.include_router(description_admin_router, prefix="/admin/description", tags=["description"]) all_routers_v2.include_router(userinfo_router, prefix="/clients", tags=["clients"]) + + +all_routers_ephemeral_v1 = APIRouter() +all_routers_ephemeral_v1.include_router( + ephemeral_takmissionpackage_router, prefix="/tak-missionpackages", tags=["ephemeral-tak-missionpackages"] +) diff --git a/src/takrmapi/api/tak_missionpackage.py b/src/takrmapi/api/tak_missionpackage.py new file mode 100644 index 00000000..c6e687bc --- /dev/null +++ b/src/takrmapi/api/tak_missionpackage.py @@ -0,0 +1,203 @@ +"""Endpoint to deliver user missionpackages from templates/tak_missionpackage""" + +from pathlib import Path +import logging +import base64 +import binascii +import urllib.parse +import time +import os +import json +import secrets +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from fastapi.responses import FileResponse +from libpvarki.middleware.mtlsheader import MTLSHeader +from libpvarki.schemas.product import UserCRUDRequest + +from takrmapi import config +from takrmapi.takutils import tak_helpers +from takrmapi.takutils.tak_pkg_helpers import TAKDataPackage, TAKPackageZip + +LOGGER = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(MTLSHeader(auto_error=True))]) + +ephemeral_router = APIRouter() + + +def hash_from_str(hash_from: str) -> str: + """Return checksum string from given string""" + ct_digest = hashes.Hash(hashes.SHA256()) + ct_digest.update(hash_from.encode("ascii")) + ct_dig = ct_digest.finalize() + try: + ct_dig_str = binascii.hexlify(ct_dig).decode() + except binascii.Error as e: + LOGGER.info("Unable to convert digest bin to string. Possible malformed query.") + LOGGER.debug(e) + return "err" + + return ct_dig_str + + +@router.post("/client-zip/{variant}.zip") +async def return_tak_zip(user: UserCRUDRequest, variant: str, background_tasks: BackgroundTasks) -> FileResponse: + """Return TAK client zip file proxied from rm api""" + + localuser = tak_helpers.UserCRUD(user) + if not localuser: + raise HTTPException(status_code=404, detail="User data not found") + + target_pkg = await create_mission_package(localuser, variant, background_tasks) + + return FileResponse( + path=target_pkg.zip_path, + media_type="application/zip", + filename=f"{localuser.callsign}_{variant}.zip", + ) + + +async def create_mission_package( + localuser: tak_helpers.UserCRUD, variant: str, background_tasks: BackgroundTasks +) -> TAKDataPackage: + """Create mission package from template""" + walk_dir = Path(config.TAK_MISSIONPKG_TEMPLATES_FOLDER) / "default" / variant + + if not walk_dir.is_dir(): + raise HTTPException(status_code=404, detail=f"Variant '{variant}' not found") + + tak_missionpkg = TAKPackageZip(localuser) + target_pkg = TAKDataPackage(template_path=walk_dir, template_type="mission") + await tak_missionpkg.create_zip_bundles(datapackages=[target_pkg]) + + if not target_pkg.zip_path or not target_pkg.zip_path.is_file(): + raise HTTPException(status_code=500, detail="Failed to generate ZIP") + + background_tasks.add_task(tak_missionpkg.helpers.remove_tmp_dir, target_pkg.zip_tmp_folder) + + return target_pkg + + +@router.post("/ephemeral/{variant}.zip") +async def return_ephemeral_dl_link(user: UserCRUDRequest, variant: str) -> dict[str, str]: + """Return an ephemeral download link to get the TAK client zip file""" + localuser = tak_helpers.UserCRUD(user) + if not localuser: + raise HTTPException(status_code=404, detail="User data not found") + request_time = int(time.time()) + + encrypted_url = generate_encrypted_ephemeral_url_fragment(user.callsign, user.uuid, variant, request_time) + + # ATAK seems to take offense if the package file name is the same between + # requests, so we add a random token to the filename. + nonce = secrets.token_hex(8) + + ephemeral_url = ( + f"https://{config.read_tak_fqdn()}:{config.PRODUCT_HTTPS_EPHEMERAL_PORT}/" + f"ephemeral/api/v1/tak-missionpackages/ephemeral/" + f"{urllib.parse.quote_plus(encrypted_url)}/{config.read_deployment_name()}_{nonce}_{variant}.zip" + ) + + LOGGER.info("Returning ephemeral url: %s", ephemeral_url) + + return {"ephemeral_url": ephemeral_url} + + +@ephemeral_router.get("/ephemeral/{ephemeral_str}/{zipfile_name}.zip") +async def return_ephemeral_tak_zip(ephemeral_str: str, background_tasks: BackgroundTasks) -> FileResponse: + """Return the TAK client zip file using an ephemeral link""" + LOGGER.info("Got ephemeral url fragment: %s", ephemeral_str) + + callsign, user_uuid, variant = parse_encrypted_ephemeral_url_fragment(ephemeral_str) + + localuser: tak_helpers.UserCRUD = tak_helpers.UserCRUD( + UserCRUDRequest(uuid=user_uuid, callsign=callsign, x509cert="") + ) + if not localuser: + raise HTTPException(status_code=404, detail="User data not found") + + target_pkg = await create_mission_package(localuser, variant, background_tasks) + + return FileResponse( + path=target_pkg.zip_path, + media_type="application/zip", + filename=f"{localuser.callsign}_{config.read_deployment_name()}_{variant}.zip", + ) + + +def generate_encrypted_ephemeral_url_fragment( + user_callsign: str, user_uuid: str, variant: str, request_time: float +) -> str: + """Return encrypted ephemeral url""" + plaintext_str: str = json.dumps({"callsign": user_callsign, "uuid": user_uuid, "variant": variant}) + iv = os.urandom(12) + encryptor = Cipher(algorithms.AES(TAKDataPackage.get_ephemeral_byteskey()), modes.GCM(iv)).encryptor() + user_payload: bytes = encryptor.update(plaintext_str.encode("ascii")) + encryptor.finalize() + user_payload_b64: str = base64.b64encode(user_payload).decode("ascii") + iv_b64: str = base64.b64encode(iv).decode("ascii") + payload_digest: str = hash_from_str(user_payload_b64 + iv_b64 + str(request_time)) + encode: str = base64.b64encode( + json.dumps( + { + "payload_b64": user_payload_b64, + "iv_b64": iv_b64, + "request_time": request_time, + "digest": payload_digest, + } + ).encode("ascii") + ).decode("ascii") + + return encode + + +def parse_encrypted_ephemeral_url_fragment(ephemeral_str: str) -> tuple[str, str, str]: + """Parse and decrypt ephemeral url and return callsign, user uuid and variant. + + Verify that the ephemeral link is not expired and that the checksum matches. + """ + try: + e_decoded = base64.b64decode(ephemeral_str.encode("ascii")).decode("ascii") + except binascii.Error as exc: + LOGGER.info("Unable to decode base64 to string. Possible malformed query.") + LOGGER.debug(exc) + raise HTTPException(status_code=404, detail="User data not found") from exc + + ephemeral_json = json.loads(e_decoded) + if ephemeral_json["request_time"] + 300 < int(time.time()): + LOGGER.info("Ephemeral link has expired.") + raise HTTPException(status_code=404, detail="User data not found") + + # TODO: probably should be a keyed hash to catch attempts to pass an incorrect link early + payload_digest: str = hash_from_str( + ephemeral_json["payload_b64"] + ephemeral_json["iv_b64"] + str(ephemeral_json["request_time"]) + ) + if payload_digest != ephemeral_json["digest"]: + LOGGER.info( + "Checksum mismatch. Calculated: {}, but got from response: {}".format( + payload_digest, ephemeral_json["digest"] + ) + ) + raise HTTPException(status_code=404, detail="User data not found") + + iv: bytes = base64.b64decode(ephemeral_json["iv_b64"].encode("ascii")) + user_payload: bytes = base64.b64decode(ephemeral_json["payload_b64"].encode("ascii")) + + decryptor = Cipher(algorithms.AES(TAKDataPackage.get_ephemeral_byteskey()), modes.GCM(iv)).encryptor() + decrypted_payload = decryptor.update(user_payload) + decryptor.finalize() + + try: + decrypted_json = json.loads(decrypted_payload.decode("ascii")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + LOGGER.exception("Decryption failure. It could be someone is trying something nasty.") + raise HTTPException(status_code=404, detail="User data not found") from exc + + variant = decrypted_json["variant"] + callsign = decrypted_json["callsign"] + user_uuid = decrypted_json["uuid"] + + LOGGER.debug("Got the following data in ephemeral user payload: {}".format(decrypted_json)) + + return callsign, user_uuid, variant diff --git a/src/takrmapi/app.py b/src/takrmapi/app.py index 06bce088..db6424dc 100644 --- a/src/takrmapi/app.py +++ b/src/takrmapi/app.py @@ -14,7 +14,7 @@ from takrmapi import config from takrmapi.takutils import tak_init from .config import LOG_LEVEL -from .api import all_routers, all_routers_v2 +from .api import all_routers, all_routers_v2, all_routers_ephemeral_v1 LOGGER = logging.getLogger(__name__) @@ -57,6 +57,7 @@ def get_app_no_init() -> FastAPI: app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=app_lifespan, version=__version__) app.include_router(router=all_routers, prefix="/api/v1") app.include_router(router=all_routers_v2, prefix="/api/v2") + app.include_router(router=all_routers_ephemeral_v1, prefix="/ephemeral/api/v1") return app diff --git a/src/takrmapi/config.py b/src/takrmapi/config.py index 9e81a37f..83cda4ac 100644 --- a/src/takrmapi/config.py +++ b/src/takrmapi/config.py @@ -51,6 +51,8 @@ def read_deployment_name() -> str: TAK_CERTS_FOLDER: Path = cfg("TAK_CERTS_FOLDER", cast=Path, default=Path("/opt/tak/data/certs/files")) RMAPI_PERSISTENT_FOLDER: Path = cfg("RMAPI_PERSISTENT_FOLDER", cast=Path, default=Path("/data/persistent")) +PRODUCT_HTTPS_EPHEMERAL_PORT: int = cfg("PRODUCT_HTTPS_EPHEMERAL_PORT", cast=int, default=4627) + # TAK vite asset graphical addons VITE_ASSET_SET: str = cfg("VITE_ASSET_SET", cast=str, default="not_used_by_default") VITE_ASSET_SET_TEMPLATES_FOLDER: Path = cfg( diff --git a/src/takrmapi/takutils/tak_pkg_helpers.py b/src/takrmapi/takutils/tak_pkg_helpers.py index 2c83cdd5..b6316b79 100644 --- a/src/takrmapi/takutils/tak_pkg_helpers.py +++ b/src/takrmapi/takutils/tak_pkg_helpers.py @@ -1,10 +1,11 @@ """Helper functions to manage tak data packages""" -from typing import List, Any, Dict +import base64 +import binascii +from typing import List, Any, Dict, ClassVar import logging from dataclasses import dataclass, field from pathlib import Path -from glob import glob import tempfile import asyncio import os @@ -35,6 +36,23 @@ class TAKPkgVars: template_file_render_str: str +def _get_secret_key_from_environ() -> bytes: + """Fetch the secret key from the environment variable.""" + + from_env = os.environ.get("TAKRMAPI_SECRET_KEY", "") + try: + key_candidate = base64.b64decode(from_env.encode("ascii")) + except (TypeError, binascii.Error): + LOGGER.warning("TAKRMAPI_SECRET_KEY is not valid base64 encoded string.") + return b"" + + if len(key_candidate) >= 32: + return key_candidate[:32] + + LOGGER.warning("TAKRMAPI_SECRET_KEY is too short. It should be at least 32 bytes long.") + return b"" + + @dataclass class TAKDataPackage: """TAK Datapackage helper""" @@ -43,6 +61,7 @@ class TAKDataPackage: template_type: str _pkgvars: TAKPkgVars = field(init=False) + ephemeral_key: ClassVar[bytes] = b"" # TODO savolaiset muuttujat # _zip_path: Path = field(init=False) @@ -79,6 +98,16 @@ def __post_init__(self) -> None: template_file_render_str="", ) + @classmethod + def get_ephemeral_byteskey(cls) -> bytes: + """Return key for ephemeral file requests""" + if not cls.ephemeral_key: + cls.ephemeral_key = _get_secret_key_from_environ() + if not cls.ephemeral_key: + raise RuntimeError("Ephemeral key not set!") + + return cls.ephemeral_key + @property def is_folder(self) -> bool: """Check if package is folder""" @@ -348,7 +377,7 @@ async def zip_folder_content(self, zipfile: str, tmp_folder: str) -> None: await asyncio.get_running_loop().run_in_executor(None, shutil.make_archive, zipfile, "zip", tmp_folder) async def tak_missionpackage_extras(self, manifest_file: Path, tmp_folder: Path) -> None: - """Check if there is some extra that needs to be done defined in manfiest""" + """Check if there is some extra that needs to be done defined in the manifest""" manifest_rows = manifest_file.read_text().splitlines() for row in manifest_rows: # .p12 rows @@ -356,7 +385,7 @@ async def tak_missionpackage_extras(self, manifest_file: Path, tmp_folder: Path) if "