using Invercargill; using Invercargill.DataStructures; using InvercargillJson; using Inversion; using Astralis; namespace Statum { /** * Resolves the slots the client currently holds from the request's * `X-Statum-Slot` headers into a type-name → {@link HeldSlot} map. * * Two header forms are supported (see `model.md`): * * - **Reference form** (`key=…; as_at=…`): the snapshot is still within its * `transmit_after` window, so the client sends only a reference. The * resolver re-uses the server-side cache via {@link StateService.get_slot} * and skips the slot if it is absent. * * - **Base64 frame form**: the snapshot's `transmit_after == as_at` * (always-send), so the client sends the full frame. The resolver * base64-decodes it, verifies the signature, parses the snapshot and * decrypts the private payload. * * Only slots whose data the client actually sent are surfaced (scoped to the * `X-Statum-Slot` headers), and each is indexed by its slot type name. */ public class HeldSlotResolver : Object { private StateService state_service = inject(); private Cryptography.SigningProvider signing_provider = inject(); private Cryptography.EncryptionProvider encryption_provider = inject(); private const string SLOT_HEADER = "X-Statum-Slot"; /** * Resolves every `X-Statum-Slot` header on `http_context`. * * - **Reference form** (`key=…; as_at=…`) for a slot still cached * server-side → resolved into {@link ResolvedSlots.held}. * - **Reference form** for a slot no longer in memory → its key is added * to {@link ResolvedSlots.transmit_keys}; the caller emits a * `transmit(retry)` so the client re-sends the frame (the slot post * endpoint re-verifies and re-caches it). * - **Base64 frame form** that verifies → resolved into * {@link ResolvedSlots.held}. * - **Base64 frame form** that fails verification (or is expired) → its * frame `signature` (read off the envelope, without trusting * `content`) is added to {@link ResolvedSlots.invalid_signatures}; the * caller emits an `invalidate`. * * Browsers coalesce multiple `X-Statum-Slot` headers into a single * comma-separated header value; neither form contains a comma, so each * header value is additionally split on `,`. */ public ResolvedSlots resolve(HttpContext http_context) { var result = new ResolvedSlots(); foreach (var header_value in http_context.request.headers.get_or_empty(SLOT_HEADER)) { foreach (var part in header_value.split(",")) { var slot_value = part.strip(); if (slot_value.length == 0) { continue; } HeldSlot? slot = null; if (slot_value.contains("key=")) { slot = resolve_reference(slot_value, result); if (slot == null || ((!)slot).type_name == null) { var key = parse_reference_key(slot_value); if (key != null) { result.transmit_keys.add((!)key); } continue; } } else { slot = resolve_frame(slot_value); if (slot == null || ((!)slot).type_name == null) { var signature = parse_frame_signature(slot_value); if (signature != null) { result.invalid_signatures.add((!)signature); } continue; } } result.held.set(((!)slot).type_name, (!)slot); } } return result; } /** Extracts the `key` from a `key=…; as_at=…` reference header value. */ private static string? parse_reference_key(string slot_value) { foreach (var part in slot_value.split(";")) { var trimmed = part.strip(); if (trimmed.has_prefix("key=")) { var key = trimmed.substring(4).strip(); return key.length > 0 ? key : null; } } return null; } /** * Reads the `signature` off a base64 frame envelope WITHOUT trusting or * parsing its `content`, so an untrustworthy/corrupt frame can still be * reported for invalidation. Returns `null` if the envelope itself * cannot be parsed. */ private static string? parse_frame_signature(string slot_value) { try { var bytes = Base64.decode(slot_value); var frame_json = to_null_terminated_string(bytes); var signature = new JsonElement.from_string(frame_json).as().get_string("signature"); return (signature != null && ((!)signature).length > 0) ? signature : null; } catch (GLib.Error e) { return null; } } private HeldSlot? resolve_reference(string header_value, ResolvedSlots result) { string? key = null; DateTime? as_at = null; foreach (var part in header_value.split(";")) { var trimmed = part.strip(); if (trimmed.has_prefix("key=")) { key = trimmed.substring(4).strip(); } else if (trimmed.has_prefix("as_at=")) { var raw = trimmed.substring(6).strip(); as_at = new DateTime.from_iso8601(raw, new TimeZone.utc()); } } if (key == null) { return null; } var slot = state_service.get_slot((!)key); if (slot == null || slot.current_state == null) { return null; } var state = slot.current_state; DateTime as_at_value = as_at; if (as_at_value == null) { if (state.last_touched != null) { as_at_value = state.last_touched; } else { as_at_value = new DateTime.now_utc(); } } // Detect staleness: if the client's as_at predates the slot's // last_touched, the client holds an outdated snapshot. if (as_at != null && state.last_touched != null && ((!)as_at).compare(state.last_touched) < 0) { result.stale_keys.add(slot.id); } return new HeldSlot() { key = slot.id, type_name = slot.type_name, scope = slot.scope, @public = state.public_data ?? new PropertyDictionary(), @private = state.private_data ?? new PropertyDictionary(), as_at = as_at_value }; } private HeldSlot? resolve_frame(string header_value) { string frame_json; try { var bytes = Base64.decode(header_value.strip()); frame_json = to_null_terminated_string(bytes); } catch (GLib.Error e) { return null; } Model.FrameDto frame; Model.SnapshotDto snapshot; try { var frame_element = new JsonElement.from_string(frame_json); frame = Model.FrameDto.get_mapper().materialise(frame_element.as()); signing_provider.verify_frame(frame); var snapshot_element = new JsonElement.from_string(frame.content); snapshot = Model.SnapshotDto.get_mapper().materialise(snapshot_element.as()); } catch (GLib.Error e) { return null; } // Drop snapshots that have passed their invalid_after time. if (snapshot.invalid_after != null) { var now = new DateTime.now_utc(); if (((!)snapshot.invalid_after).compare(now) < 0) { return null; } } var private_data = decrypt_private(snapshot); return new HeldSlot() { key = snapshot.slot.key, type_name = snapshot.slot.slot_type, scope = snapshot.slot.scope, @public = snapshot.@public ?? new PropertyDictionary(), @private = private_data, as_at = snapshot.as_at, transmit_after = snapshot.transmit_after, invalid_after = snapshot.invalid_after }; } private Properties decrypt_private(Model.SnapshotDto snapshot) { if (snapshot.@private == null) { return new PropertyDictionary(); } try { return encryption_provider.read_properties(snapshot.slot.key, (!)snapshot.@private); } catch (GLib.Error e) { return new PropertyDictionary(); } } private static string to_null_terminated_string(uint8[] bytes) { var copy = new uint8[bytes.length + 1]; Memory.copy(copy, bytes, bytes.length); return (string) copy; } } /** * The outcome of resolving a request's `X-Statum-Slot` headers. */ public class ResolvedSlots : Object { /** Successfully resolved held slots, indexed by type name. */ public Dictionary held { get; private set; default = new Dictionary(); } /** * Keys of reference-form slots no longer in the server's memory; the * caller emits a `transmit(retry)` for each so the client re-sends the * frame for the server to re-cache. */ public Series transmit_keys { get; private set; default = new Series(); } /** * Signatures of frame-form slots that failed verification (or expired); * the caller emits an `invalidate` for each so the client drops the * matching held slot. */ public Series invalid_signatures { get; private set; default = new Series(); } /** * Keys of cached slots whose client `as_at` predates the slot's * `last_touched`; the caller auto-signs and appends a `set` so the * client refreshes its stale snapshot. */ public Series stale_keys { get; private set; default = new Series(); } } }