"""Profile acceleration receipt: cross-process custody of a wrapped bucket DEK.
This is NOT a session. It has no counterparty and no protocol: it is a
locally held wrap of an already-unlocked DEK, carrying a deadline, that lets a
later process skip the passphrase. The AEAT authority session -- an encrypted
row inside the bucket, revocable only with the key -- is the artefact that
owns the word "session" in this codebase, and conflating the two has produced
a wrong architectural premise more than once.
The "logged in" state minted by ``aeat config login`` survives across CLI
processes as two split-knowledge artefacts, either of which is useless
alone:
- an ephemeral 32-byte session key held ONLY in the OS keychain under the
service ``cadrumo:profile-session:v2`` with the immutable profile UUID plus
minted session UUID as account, and
- an on-disk ``session.v2.json`` record in the separated profile keystore
directory carrying the AES-256-GCM wrap of the bucket DEK under that
session key, with EVERY metadata field (schema version, immutable profile
UUID, random session UUID,
custody generation, DEK epoch, ``issued_at``, the sliding idle deadline,
and immutable absolute deadline) bound as AEAD associated data.
A disk-only attacker sees ciphertext no more revealing than the
already-persisted wrapped ``bucket.dek.json``; a keychain-only attacker
holds a random key with nothing to decrypt; altering any persisted
deadline breaks the GCM tag. Resume evaluation is FAIL-CLOSED: an
expired, tampered, version-mismatched, or keychain-orphaned record is
deleted and refused with a typed
:class:`~cadrumo.core.profile_session.ProfileSessionRefusalReason`, never silently
tolerated. No plaintext KEK, DEK, or session-key byte ever lands on disk.
Zeroisation honesty: the session key and DEK are held in ``bytearray``
buffers wiped through :func:`~adapters.persistence.storage.custody.zeroise.zeroise`
on every exit path, but the AEAD primitives and the pydantic boundary
require transient immutable ``bytes`` views whose lifetime the garbage
collector owns — the same best-effort contract
:class:`BucketSession` documents for the live in-process buffers.
Keychain failures are normalised to :class:`KeyringUnavailableError` on every
path, including failures raised OUTSIDE the ``keyring`` library's own
exception hierarchy. A backend can pass the class-level usability probe and
still fail at call time: the Windows credential store raises
``win32ctypes.pywin32.pywintypes.error`` (for example ``WinError 1312``, "a
specified logon session does not exist") from a process whose logon session
the credential manager cannot reach, and that type derives directly from
``Exception`` -- it is neither a ``KeyringError`` nor an ``OSError``, so a
guard naming either lets it escape as a raw traceback. Normalising here is
what makes the documented degradation reachable: no persisted artefact, a
process-scoped login, and a warning to the operator. The error type is caught
structurally rather than imported by name so this module carries no
platform-specific import.
See Also:
:class:`~adapters.persistence.storage.master_key.bucket_session.BucketSession`
The in-process materialisation this persisted record re-opens.
"""
from __future__ import annotations
import binascii
import json
import secrets
from datetime import datetime, timedelta
from pathlib import Path
from typing import Final, Protocol, cast
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, ValidationError
from .....core.base64_codec import b64_decode, b64_encode
from .....core.external_constants import UTF_8_ENCODING as _UTF_8_ENCODING
from .....core.hashing import (
canonical_json_bytes,
reject_duplicate_json_members,
reject_json_constant,
)
from .....core.identity.profile import canonical_profile_bucket_id
from .....core.logging import get_logger
from .....core.models import STRICT_FROZEN_CONFIG as _STRICT_FROZEN
from .....core.profile_session import ProfileSessionRefusalReason
from .....core.time.utc import validate_utc_aware
from ..crypto.aes_gcm import KEY_SIZE
from ..errors import (
DecryptionError,
KeyringUnavailableError,
StorageError,
StorageValidationError,
)
from ..storage_path_definitions import PROFILE_SESSION_FILENAME, PROFILE_SESSION_RETIREMENT_FILENAME
from . import acceleration_receipt_crypto as _crypto
from .acceleration_receipt_crypto import encryption_error
from .errors import ProfileCustodyRecordError
from .filesystem import (
compare_and_clear_profile_custody_local_record,
compare_and_replace_profile_custody_local_record,
profile_custody_local_lock,
profile_custody_root_lock,
read_optional_profile_custody_local_record,
)
from .filesystem_primitives import ensure_profile_custody_local_directory
from .zeroise import zeroise as _zeroise
_log = get_logger(__name__)
PROFILE_SESSION_KEYCHAIN_SERVICE: Final[str] = "cadrumo:profile-session:v2"
"""OS-keychain service name for current profile acceleration-receipt keys.
The token deliberately lags the concept name. It addresses entries in the OS
credential store, OUTSIDE the storage root, so a rename orphans every existing
entry: deleting the storage root cannot reap them, and code that deleted under
the old token first would be exactly the migration path this project forbids.
Permanent credential residue on real machines is the worse outcome, so the
wire identifier stays fixed while the code name says what the artefact is.
"""
PROFILE_SESSION_RECORD_MAX_BYTES: Final[int] = 8 * 1024
"""Strict ceiling for one canonical ``session.v2.json`` receipt."""
_PROFILE_SESSION_RETIREMENT_MAX_BYTES: Final[int] = 24 * 1024
_PROFILE_SESSION_RETIREMENT_SCHEMA_VERSION: Final[int] = 1
class _AccelerationReceiptDocument(BaseModel):
"""On-disk JSON envelope for one persisted profile session."""
model_config = _STRICT_FROZEN
schema_version: int = Field(ge=1)
profile_id: UUID
session_id: UUID
custody_generation: int = Field(ge=1)
dek_epoch: str = Field(min_length=1, max_length=128)
issued_at: str = Field(min_length=1)
idle_deadline: str = Field(min_length=1)
absolute_deadline: str = Field(min_length=1)
nonce_b64: str = Field(min_length=1)
ciphertext_b64: str = Field(min_length=1)
tag_b64: str = Field(min_length=1)
class _PendingReceiptRetirementDocument(BaseModel):
"""Bounded exact-byte receipt for an interrupted session-key rotation.
``predecessor_b64`` is absent only for the first mint. Keeping both
canonical receipt byte strings makes recovery an equality decision, not a
reconstruction from mutable metadata: if the current receipt is the
predecessor the staged successor is retired; if it is the successor the
predecessor is retired. Any third receipt is a concurrent substitution
and is preserved/refused.
"""
model_config = _STRICT_FROZEN
schema_version: int = Field(ge=1)
profile_id: UUID
predecessor_b64: str | None = None
successor_b64: str = Field(min_length=1)
[docs]
class ProfileSessionResumeOutcome(BaseModel):
"""Typed outcome of a fail-closed persisted-session resume evaluation.
Never carries key material: the resumed DEK travels beside this record
as a separate return value so no pydantic dump can surface it.
"""
model_config = _STRICT_FROZEN
resumed: bool
refusal: ProfileSessionRefusalReason | None = None
record: _crypto.PersistedProfileSession | None = None
class _ProfileSessionKeyring(Protocol):
"""Direct subset of the OS keychain used by session acceleration."""
def set_password(self, service_name: str, username: str, password: str) -> None:
"""Persist one secret under the immutable account."""
...
def get_password(self, service_name: str, username: str) -> str | None:
"""Load one exact secret, or return no entry."""
...
def delete_password(self, service_name: str, username: str) -> None:
"""Remove one exact secret."""
...
def _keyring() -> tuple[_ProfileSessionKeyring, type[BaseException], type[BaseException]]:
"""Return the direct OS-keychain capability without master/provider reuse."""
try:
import keyring
from keyring.errors import KeyringError, PasswordDeleteError
except ImportError as exc:
raise KeyringUnavailableError(f"keyring package not importable: {exc}") from exc
try:
priority = float(getattr(keyring.get_keyring(), "priority", 0))
except Exception as exc:
raise KeyringUnavailableError(f"OS keychain cannot be inspected: {exc}") from exc
if priority <= 0:
raise KeyringUnavailableError("no usable OS keychain is configured for profile-session acceleration")
return cast(_ProfileSessionKeyring, keyring), KeyringError, PasswordDeleteError
def _keychain_account(*, profile_id: UUID, session_id: UUID) -> str:
"""Return the immutable UUID-pair account for one acceleration receipt."""
return f"{profile_id}:{session_id}"
def _store_acceleration_secret(*, profile_id: UUID, session_id: UUID, session_key: bytes) -> None:
"""Store and round-trip verify one fresh session key under its UUID pair."""
if len(session_key) != _crypto.PROFILE_SESSION_KEY_BYTES:
raise StorageValidationError(f"session_key must be exactly {_crypto.PROFILE_SESSION_KEY_BYTES} bytes")
keyring, keyring_error, _password_delete_error = _keyring()
account = _keychain_account(profile_id=profile_id, session_id=session_id)
encoded = b64_encode(session_key)
try:
keyring.set_password(PROFILE_SESSION_KEYCHAIN_SERVICE, account, encoded)
roundtrip = keyring.get_password(PROFILE_SESSION_KEYCHAIN_SERVICE, account)
except keyring_error as exc:
raise KeyringUnavailableError(f"OS keychain refused the profile-session key write: {exc}") from exc
except Exception as exc:
raise KeyringUnavailableError(
f"OS keychain raised unexpectedly on the profile-session key write: {exc}",
) from exc
if roundtrip != encoded:
_delete_acceleration_secret(profile_id=profile_id, session_id=session_id, suppress_unavailable=True)
raise KeyringUnavailableError("OS keychain accepted the session key but its round-trip read disagreed")
def _load_acceleration_secret(*, profile_id: UUID, session_id: UUID) -> bytes | None:
"""Load one receipt's session key; malformed entries are cache-only cleanup."""
keyring, keyring_error, _password_delete_error = _keyring()
account = _keychain_account(profile_id=profile_id, session_id=session_id)
try:
stored = keyring.get_password(PROFILE_SESSION_KEYCHAIN_SERVICE, account)
except keyring_error as exc:
raise KeyringUnavailableError(f"OS keychain refused the profile-session key read: {exc}") from exc
except Exception as exc:
raise KeyringUnavailableError(
f"OS keychain raised unexpectedly on the profile-session key read: {exc}",
) from exc
if stored is None:
return None
try:
key = b64_decode(stored)
except (ValueError, binascii.Error):
_delete_acceleration_secret(profile_id=profile_id, session_id=session_id, suppress_unavailable=False)
return None
if len(key) != _crypto.PROFILE_SESSION_KEY_BYTES:
_delete_acceleration_secret(profile_id=profile_id, session_id=session_id, suppress_unavailable=False)
return None
return key
def _read_keychain_entry_for_deletion(
keyring: _ProfileSessionKeyring,
keyring_error: type[BaseException],
account: str,
*,
refusal_detail: str,
unexpected_detail: str,
) -> str | None:
"""Read one account while preserving the deletion-phase error wording."""
try:
return keyring.get_password(PROFILE_SESSION_KEYCHAIN_SERVICE, account)
except keyring_error as exc:
raise KeyringUnavailableError(f"OS keychain refused the {refusal_detail}: {exc}") from exc
except Exception as exc:
raise KeyringUnavailableError(f"OS keychain raised unexpectedly {unexpected_detail}: {exc}") from exc
def _delete_keychain_entry(
*,
profile_id: UUID,
session_id: UUID,
) -> None:
"""Delete and independently confirm one exact UUID-pair account."""
keyring, keyring_error, password_delete_error = _keyring()
account = _keychain_account(profile_id=profile_id, session_id=session_id)
present = _read_keychain_entry_for_deletion(
keyring,
keyring_error,
account,
refusal_detail="profile-session key read before deletion",
unexpected_detail="before profile-session key deletion",
)
if present is None:
return
try:
keyring.delete_password(PROFILE_SESSION_KEYCHAIN_SERVICE, account)
except password_delete_error:
# A concurrent owner may have deleted it after ``present``. Do
# not turn that idempotent absence into a false unavailable
# outcome; only a second exact read decides it.
confirmed = _read_keychain_entry_for_deletion(
keyring,
keyring_error,
account,
refusal_detail="profile-session key deletion confirmation",
unexpected_detail="during profile-session key deletion confirmation",
)
if confirmed is None:
return
raise KeyringUnavailableError("OS keychain did not confirm profile-session key deletion") from None
except keyring_error as exc:
raise KeyringUnavailableError(f"OS keychain refused the profile-session key deletion: {exc}") from exc
except Exception as exc:
raise KeyringUnavailableError(
f"OS keychain raised unexpectedly during profile-session key deletion: {exc}",
) from exc
confirmed = _read_keychain_entry_for_deletion(
keyring,
keyring_error,
account,
refusal_detail="profile-session key deletion confirmation",
unexpected_detail="during profile-session key deletion confirmation",
)
if confirmed is not None:
raise KeyringUnavailableError("OS keychain did not confirm profile-session key deletion")
def _delete_acceleration_secret(
*,
profile_id: UUID,
session_id: UUID,
suppress_unavailable: bool,
) -> None:
"""Prove one known UUID-pair acceleration entry is absent.
A missing account is an idempotent success. A ``PasswordDeleteError``
alone is not evidence that the backend is unavailable: a sibling may have
retired this exact account after our first read. Read once more and
accept only an observed absence; otherwise preserve the calling journal
under the typed unavailable path.
"""
try:
_delete_keychain_entry(profile_id=profile_id, session_id=session_id)
except Exception as exc:
if suppress_unavailable:
_log.debug("profile-session key cleanup deferred error_type=%s", type(exc).__name__)
return
if isinstance(exc, KeyringUnavailableError):
raise
raise KeyringUnavailableError(f"OS keychain refused the profile-session key deletion: {exc}") from exc
[docs]
def profile_session_path(*, storage_root: Path, profile_id: UUID) -> Path:
"""Return the locator consumed exclusively by custody local-record operations."""
from ..bucket.keystore_paths import keystore_sidecar_path
return keystore_sidecar_path(
storage_root=storage_root,
bucket_id=canonical_profile_bucket_id(profile_id),
filename=PROFILE_SESSION_FILENAME,
)
def _profile_session_retirement_path(*, storage_root: Path, profile_id: UUID) -> Path:
"""Return the bounded local receipt for one interrupted session-key swap."""
return profile_session_path(storage_root=storage_root, profile_id=profile_id).with_name(
PROFILE_SESSION_RETIREMENT_FILENAME,
)
def _profile_session_lock_path(receipt_path: Path) -> Path:
"""Return the separate anchored lock leaf; never lock the receipt itself."""
return receipt_path.with_name(f".{receipt_path.name}.lock")
def _ensure_profile_session_directory(path: Path) -> None:
"""Provision only the two custody-owned keystore children, anchored.
The configured storage root itself is an existing product boundary. The
session sidecar owns the ``keystore`` child and then its UUID child; using
the custody primitive for both avoids a recursive convenience mkdir walk.
"""
ensure_profile_custody_local_directory(path.parent.parent)
ensure_profile_custody_local_directory(path.parent)
def _document_from_record(record: _crypto.PersistedProfileSession) -> _AccelerationReceiptDocument:
return _AccelerationReceiptDocument(
schema_version=record.schema_version,
profile_id=record.profile_id,
session_id=record.session_id,
custody_generation=record.custody_generation,
dek_epoch=record.dek_epoch,
issued_at=record.issued_at.isoformat(),
idle_deadline=record.idle_deadline.isoformat(),
absolute_deadline=record.absolute_deadline.isoformat(),
nonce_b64=b64_encode(record.nonce),
ciphertext_b64=b64_encode(record.ciphertext),
tag_b64=b64_encode(record.tag),
)
def _record_from_document(document: _AccelerationReceiptDocument) -> _crypto.PersistedProfileSession:
"""Hydrate the strict record from an on-disk document.
Raises:
ValueError: On any malformed field (base64, datetime, enum, or
length); the resume evaluation maps this to the ``MALFORMED``
refusal.
"""
return _crypto.PersistedProfileSession(
schema_version=document.schema_version,
profile_id=document.profile_id,
session_id=document.session_id,
custody_generation=document.custody_generation,
dek_epoch=document.dek_epoch,
issued_at=validate_utc_aware(datetime.fromisoformat(document.issued_at)),
idle_deadline=validate_utc_aware(datetime.fromisoformat(document.idle_deadline)),
absolute_deadline=validate_utc_aware(datetime.fromisoformat(document.absolute_deadline)),
nonce=b64_decode(document.nonce_b64),
ciphertext=b64_decode(document.ciphertext_b64),
tag=b64_decode(document.tag_b64),
)
def _canonical_document_bytes(document: BaseModel) -> bytes:
"""Encode one strict receipt with the sole accepted JSON byte spelling."""
return canonical_json_bytes(document.model_dump(mode="json"))
def _parse_canonical_document(payload: bytes, model: type[BaseModel]) -> BaseModel:
"""Decode exact canonical JSON without accepting duplicate-key aliases."""
decoded = payload.decode(_UTF_8_ENCODING)
parsed = json.loads(
decoded,
object_pairs_hook=reject_duplicate_json_members,
parse_constant=reject_json_constant,
)
if not isinstance(parsed, dict):
raise ValueError("session receipt must be a JSON object")
# ``strict`` models deliberately accept their wire UUID/datetime strings
# only through Pydantic's JSON boundary. The independent ``json.loads``
# above owns duplicate/non-finite refusal before this typed decode.
document = model.model_validate_json(payload)
if _canonical_document_bytes(document) != payload:
raise ValueError("session receipt bytes are not canonical")
return document
def _receipt_bytes(record: _crypto.PersistedProfileSession) -> bytes:
return _canonical_document_bytes(_document_from_record(record))
def _record_from_canonical_receipt(payload: bytes) -> _crypto.PersistedProfileSession:
document = cast(_AccelerationReceiptDocument, _parse_canonical_document(payload, _AccelerationReceiptDocument))
return _record_from_document(document)
def _read_receipt(path: Path) -> tuple[bytes, _crypto.PersistedProfileSession] | None:
"""Read and parse one receipt through the canonical anchored authority."""
payload = read_optional_profile_custody_local_record(path, maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES)
if payload is None:
return None
return payload, _record_from_canonical_receipt(payload)
def _clear_captured_receipt(path: Path, *, payload: bytes, maximum_bytes: int) -> bool:
"""Clear only the exact anchored bytes already evaluated by this caller."""
try:
compare_and_clear_profile_custody_local_record(
path,
expected=payload,
maximum_bytes=maximum_bytes,
)
except ProfileCustodyRecordError:
return False
return True
def _write_acceleration_receipt(
*,
storage_root: Path,
profile_id: UUID,
record: _crypto.PersistedProfileSession,
predecessor: bytes | None,
) -> bytes:
"""CAS-publish a canonical receipt through the custody local-record owner."""
if record.profile_id != profile_id:
raise StorageValidationError(
f"session record profile {record.profile_id!s} does not match target profile {profile_id!s}",
)
path = profile_session_path(storage_root=storage_root, profile_id=profile_id)
payload = _receipt_bytes(record)
_ensure_profile_session_directory(path)
compare_and_replace_profile_custody_local_record(
path,
expected=predecessor,
replacement=payload,
maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES,
)
return payload
def _pending_retirement_bytes(*, profile_id: UUID, predecessor: bytes | None, successor: bytes) -> bytes:
"""Encode the bounded exact-byte retirement decision before publication."""
return _canonical_document_bytes(
_PendingReceiptRetirementDocument(
schema_version=_PROFILE_SESSION_RETIREMENT_SCHEMA_VERSION,
profile_id=profile_id,
predecessor_b64=None if predecessor is None else b64_encode(predecessor),
successor_b64=b64_encode(successor),
),
)
def _read_pending_retirement(path: Path, *, profile_id: UUID) -> tuple[bytes, bytes | None, bytes] | None:
"""Load a current pending-swap receipt, rejecting aliases before action."""
payload = read_optional_profile_custody_local_record(path, maximum_bytes=_PROFILE_SESSION_RETIREMENT_MAX_BYTES)
if payload is None:
return None
document = cast(
_PendingReceiptRetirementDocument,
_parse_canonical_document(payload, _PendingReceiptRetirementDocument),
)
if document.schema_version != _PROFILE_SESSION_RETIREMENT_SCHEMA_VERSION:
raise StorageValidationError("profile-session retirement receipt schema is not current")
if document.profile_id != profile_id:
raise StorageValidationError("profile-session retirement receipt profile differs")
predecessor = None if document.predecessor_b64 is None else b64_decode(document.predecessor_b64)
successor = b64_decode(document.successor_b64)
if predecessor is not None:
prior_record = _record_from_canonical_receipt(predecessor)
if prior_record.profile_id != profile_id:
raise StorageValidationError("profile-session retirement predecessor profile differs")
successor_record = _record_from_canonical_receipt(successor)
if successor_record.profile_id != profile_id:
raise StorageValidationError("profile-session retirement successor profile differs")
return payload, predecessor, successor
def _recover_pending_retirement(*, storage_root: Path, profile_id: UUID) -> bool:
"""Finish exactly one interrupted ordered session-key swap.
Returns ``True`` only when there was no journal or the exact journal was
consumed. It never clears a receipt whose bytes differ from the recorded
predecessor/successor pair.
"""
receipt_path = profile_session_path(storage_root=storage_root, profile_id=profile_id)
journal_path = _profile_session_retirement_path(storage_root=storage_root, profile_id=profile_id)
pending = _read_pending_retirement(journal_path, profile_id=profile_id)
if pending is None:
return True
journal_payload, predecessor, successor = pending
current = read_optional_profile_custody_local_record(
receipt_path,
maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES,
)
if current == successor:
if predecessor is not None:
prior = _record_from_canonical_receipt(predecessor)
_delete_acceleration_secret(
profile_id=prior.profile_id,
session_id=prior.session_id,
suppress_unavailable=False,
)
if not _clear_captured_receipt(
journal_path,
payload=journal_payload,
maximum_bytes=_PROFILE_SESSION_RETIREMENT_MAX_BYTES,
):
raise StorageValidationError("profile-session retirement receipt changed during recovery")
return True
if current == predecessor:
successor_record = _record_from_canonical_receipt(successor)
_delete_acceleration_secret(
profile_id=successor_record.profile_id,
session_id=successor_record.session_id,
suppress_unavailable=False,
)
if not _clear_captured_receipt(
journal_path,
payload=journal_payload,
maximum_bytes=_PROFILE_SESSION_RETIREMENT_MAX_BYTES,
):
raise StorageValidationError("profile-session retirement receipt changed during rollback")
return True
raise StorageValidationError("profile-session retirement receipt conflicts with the current receipt")
def _discard_known_record(*, path: Path, payload: bytes, record: _crypto.PersistedProfileSession) -> bool:
"""Revoke one verified cache entry, then clear the exact captured receipt."""
try:
_delete_acceleration_secret(
profile_id=record.profile_id,
session_id=record.session_id,
suppress_unavailable=False,
)
except KeyringUnavailableError:
return False
return _clear_captured_receipt(path, payload=payload, maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES)
[docs]
class AccelerationReceiptRevocationError(StorageError):
"""Raised when a receipt survived the revocation that was asked to remove it.
Deliberately NOT a subclass of the custody record error this module already
catches and logs, because the point is that it must not be swallowed by the
same handler.
A boolean return would have repeated the defect one level up: the caller
that ignored the value is precisely the caller that reports the profile
closed. The revocation entry point is the single authority for "this
profile is no longer logged in", so when it cannot prove the receipt is
gone it must not return as though it had.
"""
[docs]
def delete_profile_session(*, storage_root: Path, profile_id: UUID) -> None:
"""Revoke a profile receipt under the custody-wide session lifecycle lock."""
try:
with profile_custody_root_lock(storage_root):
_delete_profile_session(storage_root=storage_root, profile_id=profile_id)
except ProfileCustodyRecordError as exc:
_log.debug("profile-session receipt deletion refused error_type=%s", type(exc).__name__)
def _delete_profile_session(*, storage_root: Path, profile_id: UUID) -> None:
"""Revoke only receipts whose exact bytes/name were safely observed.
Raises:
AccelerationReceiptRevocationError: The compare-and-clear refused, so
the receipt is still on disk. Reached whenever the clear cannot
remove the file -- an open handle on Windows is the ordinary case,
not only a byte substitution racing the lock -- and previously this
returned silently while the receipt survived.
"""
path = profile_session_path(storage_root=storage_root, profile_id=profile_id)
try:
_ensure_profile_session_directory(path)
with profile_custody_local_lock(_profile_session_lock_path(path)):
try:
_recover_pending_retirement(storage_root=storage_root, profile_id=profile_id)
except (KeyringUnavailableError, ProfileCustodyRecordError, StorageValidationError) as exc:
_log.debug("profile-session retirement recovery deferred error_type=%s", type(exc).__name__)
try:
observed = _read_receipt(path)
except (ProfileCustodyRecordError, ValueError, ValidationError) as exc:
_log.debug("profile-session receipt cannot be decoded for deletion error_type=%s", type(exc).__name__)
return
if observed is None:
return
payload, record = observed
if record.profile_id == profile_id:
_delete_acceleration_secret(
profile_id=record.profile_id,
session_id=record.session_id,
suppress_unavailable=True,
)
if not _clear_captured_receipt(path, payload=payload, maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES):
raise AccelerationReceiptRevocationError(
translated_message="errors.fail.fail_acceleration_receipt_revocation",
context={"profile_id": canonical_profile_bucket_id(profile_id)},
)
except ProfileCustodyRecordError as exc:
_log.debug("profile-session receipt deletion refused error_type=%s", type(exc).__name__)
[docs]
def mint_profile_session(
*,
storage_root: Path,
profile_id: UUID,
custody_generation: int,
dek_epoch: str,
dek: bytes,
now: datetime,
idle_minutes: int,
absolute_minutes: int,
) -> _crypto.PersistedProfileSession:
"""Mint a profile receipt under the custody-wide session lifecycle lock.
Validate all deterministic receipt inputs before opening the custody root.
A rejected bootstrap call must not provision a durable lock leaf merely
to report malformed identity, metadata, key material, or time windows.
"""
if idle_minutes <= 0:
raise StorageValidationError("idle_minutes must be a strict positive integer")
if absolute_minutes <= 0:
raise StorageValidationError("absolute_minutes must be a strict positive integer")
if len(dek) != KEY_SIZE:
raise encryption_error(f"dek must be exactly {KEY_SIZE} bytes")
now = _crypto.validate_profile_session_metadata(
profile_id=profile_id,
custody_generation=custody_generation,
dek_epoch=dek_epoch,
issued_at=now,
)
with profile_custody_root_lock(storage_root):
return _mint_profile_session(
storage_root=storage_root,
profile_id=profile_id,
custody_generation=custody_generation,
dek_epoch=dek_epoch,
dek=dek,
now=now,
idle_minutes=idle_minutes,
absolute_minutes=absolute_minutes,
)
def _mint_profile_session(
*,
storage_root: Path,
profile_id: UUID,
custody_generation: int,
dek_epoch: str,
dek: bytes,
now: datetime,
idle_minutes: int,
absolute_minutes: int,
) -> _crypto.PersistedProfileSession:
"""Mint the persisted session for a freshly-authenticated login.
Generates the ephemeral session key, wraps ``dek`` under it with the
deadline metadata AAD-bound, stores the key in the OS keychain
(round-trip verified), and atomically writes the on-disk record. The
session-key buffer is zeroised on every exit path. On a host with no
usable keychain the mint REFUSES before writing anything, so no
artefact can exist whose key has no secure home.
Args:
storage_root: The Cadrumo storage root owning the profile keystore.
profile_id: Immutable authenticated capsule UUID.
custody_generation: Current envelope generation.
dek_epoch: Current envelope DEK epoch.
dek: The unwrapped 32-byte bucket DEK to session-wrap.
now: UTC login instant (becomes ``issued_at``).
idle_minutes: Sliding idle window; strict positive.
absolute_minutes: Immutable absolute cap; strict positive.
Returns:
The persisted :class:`~.acceleration_receipt_crypto.PersistedProfileSession`.
Raises:
StorageValidationError: On non-positive windows.
EncryptionError: On wrap failure.
KeyringUnavailableError: When the OS keychain cannot custody the
session key.
"""
absolute_deadline = now + timedelta(minutes=absolute_minutes)
idle_deadline = min(now + timedelta(minutes=idle_minutes), absolute_deadline)
receipt_path = profile_session_path(storage_root=storage_root, profile_id=profile_id)
retirement_path = _profile_session_retirement_path(storage_root=storage_root, profile_id=profile_id)
_ensure_profile_session_directory(receipt_path)
with profile_custody_local_lock(_profile_session_lock_path(receipt_path)):
# A prior crash or retirement failure has one bounded exact-byte
# decision to finish before another random account can be minted.
_recover_pending_retirement(storage_root=storage_root, profile_id=profile_id)
prior = _read_receipt(receipt_path)
predecessor = None if prior is None else prior[0]
session_key_buffer = bytearray(secrets.token_bytes(_crypto.PROFILE_SESSION_KEY_BYTES))
session_id = uuid4()
try:
record = _crypto.wrap_profile_session_dek(
session_key=bytes(session_key_buffer),
dek=dek,
profile_id=profile_id,
session_id=session_id,
custody_generation=custody_generation,
dek_epoch=dek_epoch,
issued_at=now,
idle_deadline=idle_deadline,
absolute_deadline=absolute_deadline,
)
successor = _receipt_bytes(record)
journal = _pending_retirement_bytes(
profile_id=profile_id,
predecessor=predecessor,
successor=successor,
)
# Publish the deterministic cleanup instruction first. If this
# process dies before a later phase, recovery sees whether the
# exact current receipt is predecessor or successor and retires
# only the corresponding staged/displaced UUID-pair account.
compare_and_replace_profile_custody_local_record(
retirement_path,
expected=None,
replacement=journal,
maximum_bytes=_PROFILE_SESSION_RETIREMENT_MAX_BYTES,
)
try:
_store_acceleration_secret(
profile_id=profile_id,
session_id=session_id,
session_key=bytes(session_key_buffer),
)
published = _write_acceleration_receipt(
storage_root=storage_root,
profile_id=profile_id,
record=record,
predecessor=predecessor,
)
verified = read_optional_profile_custody_local_record(
receipt_path,
maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES,
)
if verified != published:
raise StorageValidationError("profile-session receipt publication could not be verified")
except BaseException:
# Do not hand-delete an account whose publication state is
# unknown. The exact pending receipt is the recovery owner;
# it will clean a pre-publication successor or finish
# retirement after publication without touching a substitute.
try:
_recover_pending_retirement(storage_root=storage_root, profile_id=profile_id)
except (KeyringUnavailableError, ProfileCustodyRecordError, StorageValidationError) as exc:
_log.debug("profile-session mint cleanup deferred error_type=%s", type(exc).__name__)
raise
try:
_recover_pending_retirement(storage_root=storage_root, profile_id=profile_id)
except KeyringUnavailableError as exc:
# The new receipt/key pair is already verified and remains a
# valid acceleration. Retiring its displaced predecessor is
# explicitly retryable through the journal, never an
# authentication failure or a global account sweep.
_log.debug("profile-session predecessor retirement deferred error_type=%s", type(exc).__name__)
return record
finally:
_zeroise(session_key_buffer)
def _refusal(
reason: ProfileSessionRefusalReason,
record: _crypto.PersistedProfileSession | None = None,
) -> tuple[ProfileSessionResumeOutcome, None]:
return ProfileSessionResumeOutcome(resumed=False, refusal=reason, record=record), None
def _resume_artifacts_present(*, path: Path, retirement_path: Path) -> bool:
"""Observe whether either receipt artifact exists without taking a lock."""
if (
read_optional_profile_custody_local_record(
path,
maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES,
)
is not None
):
return True
return (
read_optional_profile_custody_local_record(
retirement_path,
maximum_bytes=_PROFILE_SESSION_RETIREMENT_MAX_BYTES,
)
is not None
)
def _discard_resume_record_or_refuse(
*,
path: Path,
payload: bytes,
record: _crypto.PersistedProfileSession,
reason: ProfileSessionRefusalReason,
) -> tuple[ProfileSessionResumeOutcome, None]:
"""Revoke a known receipt before returning its typed refusal."""
if not _discard_known_record(path=path, payload=payload, record=record):
return _refusal(ProfileSessionRefusalReason.KEYRING_UNAVAILABLE, record)
return _refusal(reason, record)
def _resume_record_refusal(
*,
path: Path,
payload: bytes,
record: _crypto.PersistedProfileSession,
profile_id: UUID,
custody_generation: int,
dek_epoch: str,
now: datetime,
) -> tuple[ProfileSessionResumeOutcome, None] | None:
"""Apply ordered metadata and deadline checks to one parsed receipt."""
if record.schema_version != _crypto.PROFILE_SESSION_SCHEMA_VERSION:
return _discard_resume_record_or_refuse(
path=path,
payload=payload,
record=record,
reason=ProfileSessionRefusalReason.SCHEMA_VERSION_MISMATCH,
)
if record.profile_id != profile_id:
return _discard_resume_record_or_refuse(
path=path,
payload=payload,
record=record,
reason=ProfileSessionRefusalReason.TAMPERED,
)
if record.custody_generation != custody_generation or record.dek_epoch != dek_epoch:
return _discard_resume_record_or_refuse(
path=path,
payload=payload,
record=record,
reason=ProfileSessionRefusalReason.CUSTODY_CHANGED,
)
if now >= record.absolute_deadline:
return _discard_resume_record_or_refuse(
path=path,
payload=payload,
record=record,
reason=ProfileSessionRefusalReason.EXPIRED_ABSOLUTE,
)
if now >= record.idle_deadline:
return _discard_resume_record_or_refuse(
path=path,
payload=payload,
record=record,
reason=ProfileSessionRefusalReason.EXPIRED_IDLE,
)
return None
def _resume_profile_dek(
*,
path: Path,
payload: bytes,
record: _crypto.PersistedProfileSession,
) -> tuple[ProfileSessionResumeOutcome, bytearray | None]:
"""Load the split key and unwrap the DEK after metadata admission."""
try:
session_key = _load_acceleration_secret(profile_id=record.profile_id, session_id=record.session_id)
except KeyringUnavailableError:
return _refusal(ProfileSessionRefusalReason.KEYRING_UNAVAILABLE, record)
if session_key is None:
_clear_captured_receipt(path, payload=payload, maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES)
return _refusal(ProfileSessionRefusalReason.KEYCHAIN_ENTRY_MISSING, record)
session_key_buffer = bytearray(session_key)
del session_key
try:
dek = _crypto.unwrap_profile_session_dek(session_key=bytes(session_key_buffer), record=record)
except DecryptionError:
if not _discard_known_record(path=path, payload=payload, record=record):
return _refusal(ProfileSessionRefusalReason.KEYRING_UNAVAILABLE, record)
return _refusal(ProfileSessionRefusalReason.TAMPERED, record)
finally:
_zeroise(session_key_buffer)
return ProfileSessionResumeOutcome(resumed=True, refusal=None, record=record), dek
def _resume_profile_session_locked(
*,
path: Path,
profile_id: UUID,
custody_generation: int,
dek_epoch: str,
now: datetime,
) -> tuple[ProfileSessionResumeOutcome, bytearray | None]:
"""Evaluate the receipt after the profile-local lock is held."""
payload = read_optional_profile_custody_local_record(
path,
maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES,
)
if payload is None:
return _refusal(ProfileSessionRefusalReason.ABSENT)
try:
record = _record_from_canonical_receipt(payload)
except (ValueError, ValidationError):
_log.debug("profile-session record malformed; refusing profile_id=%s", profile_id)
_clear_captured_receipt(path, payload=payload, maximum_bytes=PROFILE_SESSION_RECORD_MAX_BYTES)
return _refusal(ProfileSessionRefusalReason.MALFORMED)
refusal = _resume_record_refusal(
path=path,
payload=payload,
record=record,
profile_id=profile_id,
custody_generation=custody_generation,
dek_epoch=dek_epoch,
now=now,
)
if refusal is not None:
return refusal
return _resume_profile_dek(path=path, payload=payload, record=record)
[docs]
def resume_profile_session(
*,
storage_root: Path,
profile_id: UUID,
custody_generation: int,
dek_epoch: str,
now: datetime,
) -> tuple[ProfileSessionResumeOutcome, bytearray | None]:
"""Evaluate a provisioned profile receipt under the custody-wide lifecycle lock.
The active-profile transaction provisions the root lock before a profile
can reach this resume path. A raw first call against an unprovisioned
storage root is therefore bootstrap work, not an observational refusal;
established logged-out profiles take the lock without changing their
session or retirement leaves.
"""
now = validate_utc_aware(now)
try:
with profile_custody_root_lock(storage_root):
return _resume_profile_session(
storage_root=storage_root,
profile_id=profile_id,
custody_generation=custody_generation,
dek_epoch=dek_epoch,
now=now,
)
except (ProfileCustodyRecordError, StorageValidationError, ValueError, ValidationError) as exc:
_log.debug("profile-session receipt access refused error_type=%s", type(exc).__name__)
return _refusal(ProfileSessionRefusalReason.MALFORMED)
def _resume_profile_session(
*,
storage_root: Path,
profile_id: UUID,
custody_generation: int,
dek_epoch: str,
now: datetime,
) -> tuple[ProfileSessionResumeOutcome, bytearray | None]:
"""Fail-closed evaluation of one current profile acceleration receipt.
The recovered key is a WIPEABLE ``bytearray``, and the annotation says so:
the caller owns it and is expected to zeroise it once the session holds its
own copy. Declaring immutable ``bytes`` here would leave every caller typed
to receive material it cannot wipe, which is what the unwrap was widened to
avoid.
Expired and tampered receipts are narrowly removed when the matching
keychain entry is reachable. If the OS keychain is unavailable, the
receipt remains evidence and the typed ``KEYRING_UNAVAILABLE`` refusal
leaves the login process-scoped:
- absent record: refusal ``ABSENT`` (ordinary logged-out state, nothing
to delete);
- unreadable / strict-validation failure: delete both artefacts,
``MALFORMED``;
- non-current ``schema_version`` or a record naming another bucket:
delete both, ``SCHEMA_VERSION_MISMATCH`` / ``TAMPERED``;
- elapsed absolute or idle deadline: delete both,
``EXPIRED_ABSOLUTE`` / ``EXPIRED_IDLE``;
- missing keychain entry: delete the record, ``KEYCHAIN_ENTRY_MISSING``
(logged-out treatment);
- AEAD tag failure: delete both, ``TAMPERED``.
Args:
storage_root: The Cadrumo storage root owning the profile keystore.
profile_id: Immutable capsule UUID expected by the live pointer.
custody_generation: Current authenticated envelope generation.
dek_epoch: Current authenticated envelope DEK epoch.
now: UTC instant of the evaluation.
Returns:
A ``(outcome, dek)`` pair. ``dek`` is the unwrapped 32-byte bucket
DEK when ``outcome.resumed`` is true and ``None`` otherwise; it is
deliberately NOT carried on the outcome model so key material never
rides a dumpable record.
Raises:
KeyringUnavailableError: When the keychain backend is unusable (the
caller decides between refusal and the no-persistence path).
"""
path = profile_session_path(storage_root=storage_root, profile_id=profile_id)
retirement_path = _profile_session_retirement_path(storage_root=storage_root, profile_id=profile_id)
try:
_ensure_profile_session_directory(path)
# The ordinary logged-out path must stay observational. The local
# lock is a durable kernel-owned leaf, so taking it merely to discover
# that neither session artifact exists would turn a refused root
# secret into a storage mutation. A pending retirement journal is
# itself state that needs the locked recovery path, even when no
# receipt currently exists.
if not _resume_artifacts_present(path=path, retirement_path=retirement_path):
return _refusal(ProfileSessionRefusalReason.ABSENT)
with profile_custody_local_lock(_profile_session_lock_path(path)):
# A predecessor cleanup failure is non-authoritative: attempt
# deterministic convergence before using the successor, but do
# not unlock through a substituted/ambiguous journal.
try:
_recover_pending_retirement(storage_root=storage_root, profile_id=profile_id)
except KeyringUnavailableError:
observed = _read_receipt(path)
return _refusal(
ProfileSessionRefusalReason.KEYRING_UNAVAILABLE,
None if observed is None else observed[1],
)
return _resume_profile_session_locked(
path=path,
profile_id=profile_id,
custody_generation=custody_generation,
dek_epoch=dek_epoch,
now=now,
)
except (ProfileCustodyRecordError, StorageValidationError, ValueError, ValidationError) as exc:
_log.debug("profile-session receipt access refused error_type=%s", type(exc).__name__)
return _refusal(ProfileSessionRefusalReason.MALFORMED)
[docs]
def advance_persisted_profile_session_idle_deadline(
*,
storage_root: Path,
profile_id: UUID,
record: _crypto.PersistedProfileSession,
new_idle_deadline: datetime,
) -> _crypto.PersistedProfileSession:
"""Advance a profile receipt under the custody-wide session lifecycle lock."""
if record.profile_id != profile_id:
raise StorageValidationError("persisted session record belongs to another profile")
new_idle_deadline = validate_utc_aware(new_idle_deadline)
with profile_custody_root_lock(storage_root):
return _advance_persisted_profile_session_idle_deadline(
storage_root=storage_root,
profile_id=profile_id,
record=record,
new_idle_deadline=new_idle_deadline,
)
def _advance_persisted_profile_session_idle_deadline(
*,
storage_root: Path,
profile_id: UUID,
record: _crypto.PersistedProfileSession,
new_idle_deadline: datetime,
) -> _crypto.PersistedProfileSession:
"""Advance an already-resumed receipt without exposing its key upstream."""
path = profile_session_path(storage_root=storage_root, profile_id=profile_id)
_ensure_profile_session_directory(path)
with profile_custody_local_lock(_profile_session_lock_path(path)):
observed = _read_receipt(path)
if observed is None:
raise KeyringUnavailableError("profile-session receipt disappeared before idle renewal")
predecessor, current = observed
if current != record:
raise StorageValidationError("profile-session receipt changed before idle renewal")
session_key = _load_acceleration_secret(profile_id=record.profile_id, session_id=record.session_id)
if session_key is None:
raise KeyringUnavailableError("profile-session keychain entry disappeared before idle renewal")
key_buffer = bytearray(session_key)
try:
advanced = _crypto.advance_profile_session_idle_deadline(
record=record,
session_key=bytes(key_buffer),
new_idle_deadline=new_idle_deadline,
)
_write_acceleration_receipt(
storage_root=storage_root,
profile_id=profile_id,
record=advanced,
predecessor=predecessor,
)
return advanced
finally:
_zeroise(key_buffer)
__all__ = [
"PROFILE_SESSION_KEYCHAIN_SERVICE",
"ProfileSessionResumeOutcome",
"advance_persisted_profile_session_idle_deadline",
"delete_profile_session",
"mint_profile_session",
"profile_session_path",
"resume_profile_session",
]