"""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 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 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 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