QTZPOC-20: Implement .sientia model bundle import workflow Introduces a new Temporal workflow and activities to import .sientia model bundles. This enables: - Secure decryption and validation of bundles through a seven-gate process. - Provisioning imported models in MLflow and updating the MongoDB model listing. - Comprehensive status tracking in the public.experiment_run table with new IMPORT_SUCCESS/IMPORT_ERROR states. - Dedicated metrics, robust error handling, and configurable settings. - Includes updated e2e database fixtures and extensive unit/integration tests for bundle format, reader, and overall workflow.
99 lines
3.8 KiB
Python
99 lines
3.8 KiB
Python
"""The bundle password envelope: AES-256-GCM, opened inside the activity that uses the password.
|
|
|
|
The workflow only ever carries `password_envelope`, so no plaintext password reaches the Temporal
|
|
event history. This module is imported by the bundle-opening activity and by nothing else — in
|
|
particular not by `laborious/workflows/import_model.py`, which a test asserts.
|
|
|
|
Envelope layout, chosen because WebCrypto gives the Angular frontend AES-GCM natively:
|
|
|
|
base64( nonce(12 bytes) || ciphertext || GCM tag(16 bytes) )
|
|
|
|
The key is a 32-byte value held in a Kubernetes Secret and exposed as `IMPORT_PASSWORD_KEY`,
|
|
base64-encoded. Neither the key, the envelope nor the recovered password is ever logged, notified,
|
|
used as a metric label or written to the import record.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
from typing import Final
|
|
|
|
from cryptography.exceptions import InvalidTag
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
|
|
NONCE_SIZE: Final[int] = 12
|
|
TAG_SIZE: Final[int] = 16
|
|
KEY_SIZE: Final[int] = 32
|
|
MIN_ENVELOPE_SIZE: Final[int] = NONCE_SIZE + TAG_SIZE
|
|
|
|
|
|
class EnvelopeError(Exception):
|
|
"""The envelope or its key is unusable.
|
|
|
|
Deliberately one type for every cause — missing key, wrong key, malformed base64, altered
|
|
bytes — because the failure is reported at the decryption step either way. What it must never
|
|
be confused with is a wrong *bundle* password: that one comes from the reader's gate 3, and the
|
|
two carry different sentences.
|
|
"""
|
|
|
|
|
|
def load_envelope_key(raw_key: str | None) -> bytes:
|
|
"""Decode the configured envelope key.
|
|
|
|
Args:
|
|
raw_key: the value of `IMPORT_PASSWORD_KEY` — base64 of exactly 32 bytes.
|
|
|
|
Returns:
|
|
bytes: the 32-byte key.
|
|
|
|
Raises:
|
|
EnvelopeError: when the value is absent, not base64, or not 32 bytes long. There is no
|
|
fallback interpretation: an unset key never means "the envelope is plaintext".
|
|
"""
|
|
if not raw_key:
|
|
raise EnvelopeError('the import password key is not configured')
|
|
try:
|
|
key = base64.b64decode(raw_key, validate=True)
|
|
except (binascii.Error, ValueError) as error:
|
|
raise EnvelopeError('the import password key is not valid base64') from error
|
|
if len(key) != KEY_SIZE:
|
|
raise EnvelopeError(f'the import password key must be {KEY_SIZE} bytes')
|
|
return key
|
|
|
|
|
|
def decrypt_password_envelope(envelope: str, key: bytes) -> str:
|
|
"""Recover the bundle password from its envelope.
|
|
|
|
Args:
|
|
envelope: base64 of `nonce || ciphertext || tag`.
|
|
key: the 32-byte key from `load_envelope_key`.
|
|
|
|
Returns:
|
|
str: the plaintext password, exactly as it was typed — not trimmed, not case-folded. NFC
|
|
normalisation happens at key derivation, where the producer does it.
|
|
|
|
Raises:
|
|
EnvelopeError: malformed envelope, wrong key or altered bytes (AES-GCM authentication).
|
|
"""
|
|
if not envelope:
|
|
raise EnvelopeError('the import request carries no password envelope')
|
|
try:
|
|
payload = base64.b64decode(envelope, validate=True)
|
|
except (binascii.Error, ValueError) as error:
|
|
raise EnvelopeError('the password envelope is not valid base64') from error
|
|
if len(payload) < MIN_ENVELOPE_SIZE:
|
|
raise EnvelopeError('the password envelope is too short to hold a nonce and a tag')
|
|
|
|
nonce, sealed = payload[:NONCE_SIZE], payload[NONCE_SIZE:]
|
|
try:
|
|
plaintext = AESGCM(key).decrypt(nonce, sealed, None)
|
|
except InvalidTag as error:
|
|
raise EnvelopeError('the password envelope could not be authenticated') from error
|
|
except ValueError as error:
|
|
raise EnvelopeError('the password envelope could not be opened') from error
|
|
try:
|
|
return plaintext.decode('utf-8')
|
|
except UnicodeDecodeError as error:
|
|
raise EnvelopeError('the recovered password is not valid UTF-8') from error
|