Source code for scim2_server.cursor

"""The cursors of :rfc:`RFC 9865 <9865>`, which hide the position of a page from the client.

This module needs the ``cursor`` extra, which installs ``cryptography``.
"""

import base64
import json
import os
import time
from typing import Any

from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from scim2_models import ExpiredCursorException
from scim2_models import InvalidCountException
from scim2_models import InvalidCursorException

VERSION = b"\x01"
SALT_SIZE = 16
NONCE_SIZE = 12
TAG_SIZE = 16
HEADER_SIZE = len(VERSION) + SALT_SIZE + NONCE_SIZE


[docs] class Cursors: """Seal the position of a page into a cursor, and open the cursor back. A cursor is encrypted and authenticated with AES-GCM, so the client can neither read nor forge it (:rfc:`RFC 9865 §5.2 <9865#section-5.2>`). It holds the query it belongs to, the ``count`` of the query, and the time it was issued. Each cursor has its own key, derived from the secret and a random salt. The number of cursors a secret seals is then not bounded by the number of messages AES-GCM allows for a single key. :param secret: The secret the keys of the cursors derive from. Every process of the server needs the same secret. """ def __init__(self, secret: str | bytes): self.secret = secret.encode() if isinstance(secret, str) else secret def _aead(self, salt: bytes) -> AESGCM: """Return the cipher of the cursors sealed with a salt.""" key = HKDF( algorithm=hashes.SHA256(), length=32, salt=salt, info=b"scim2-server cursor", ).derive(self.secret) return AESGCM(key)
[docs] def seal(self, query: str, count: int | None, position: Any) -> str: """Return the cursor of a position, for the query and the count it belongs to.""" content = json.dumps( {"q": query, "n": count, "t": int(time.time()), "p": position}, separators=(",", ":"), ).encode() salt = os.urandom(SALT_SIZE) nonce = os.urandom(NONCE_SIZE) sealed = self._aead(salt).encrypt(nonce, content, VERSION) token = VERSION + salt + nonce + sealed return base64.urlsafe_b64encode(token).rstrip(b"=").decode()
[docs] def open( self, cursor: str, query: str, count: int | None, timeout: int | None ) -> Any: """Return the position a cursor holds. :param timeout: The number of seconds a cursor stays valid, if any. :raises ~scim2_models.InvalidCursorException: When the cursor was not issued by this server, or for another query. :raises ~scim2_models.InvalidCountException: When the cursor was issued for another ``count``. :raises ~scim2_models.ExpiredCursorException: When the cursor is older than ``timeout``. """ try: token = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) except ValueError: raise InvalidCursorException from None if token[:1] != VERSION or len(token) < HEADER_SIZE + TAG_SIZE: raise InvalidCursorException salt = token[len(VERSION) : len(VERSION) + SALT_SIZE] nonce = token[len(VERSION) + SALT_SIZE : HEADER_SIZE] try: content = json.loads( self._aead(salt).decrypt(nonce, token[HEADER_SIZE:], VERSION) ) except InvalidTag: raise InvalidCursorException from None if content["q"] != query: raise InvalidCursorException if content["n"] != count: raise InvalidCountException if timeout is not None and time.time() - content["t"] > timeout: raise ExpiredCursorException return content["p"]