HeldSlotResolver.vala 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using InvercargillJson;
  4. using Inversion;
  5. using Astralis;
  6. namespace Statum {
  7. /**
  8. * Resolves the slots the client currently holds from the request's
  9. * `X-Statum-Slot` headers into a type-name → {@link HeldSlot} map.
  10. *
  11. * Two header forms are supported (see `model.md`):
  12. *
  13. * - **Reference form** (`key=…; as_at=…`): the snapshot is still within its
  14. * `transmit_after` window, so the client sends only a reference. The
  15. * resolver re-uses the server-side cache via {@link StateService.get_slot}
  16. * and skips the slot if it is absent.
  17. *
  18. * - **Base64 frame form**: the snapshot's `transmit_after == as_at`
  19. * (always-send), so the client sends the full frame. The resolver
  20. * base64-decodes it, verifies the signature, parses the snapshot and
  21. * decrypts the private payload.
  22. *
  23. * Only slots whose data the client actually sent are surfaced (scoped to the
  24. * `X-Statum-Slot` headers), and each is indexed by its slot type name.
  25. */
  26. public class HeldSlotResolver : Object {
  27. private StateService state_service = inject<StateService>();
  28. private Cryptography.SigningProvider signing_provider = inject<Cryptography.SigningProvider>();
  29. private Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
  30. private const string SLOT_HEADER = "X-Statum-Slot";
  31. /**
  32. * Resolves every `X-Statum-Slot` header on `http_context`.
  33. *
  34. * - **Reference form** (`key=…; as_at=…`) for a slot still cached
  35. * server-side → resolved into {@link ResolvedSlots.held}.
  36. * - **Reference form** for a slot no longer in memory → its key is added
  37. * to {@link ResolvedSlots.transmit_keys}; the caller emits a
  38. * `transmit(retry)` so the client re-sends the frame (the slot post
  39. * endpoint re-verifies and re-caches it).
  40. * - **Base64 frame form** that verifies → resolved into
  41. * {@link ResolvedSlots.held}.
  42. * - **Base64 frame form** that fails verification (or is expired) → its
  43. * frame `signature` (read off the envelope, without trusting
  44. * `content`) is added to {@link ResolvedSlots.invalid_signatures}; the
  45. * caller emits an `invalidate`.
  46. *
  47. * Browsers coalesce multiple `X-Statum-Slot` headers into a single
  48. * comma-separated header value; neither form contains a comma, so each
  49. * header value is additionally split on `,`.
  50. */
  51. public ResolvedSlots resolve(HttpContext http_context) {
  52. var result = new ResolvedSlots();
  53. foreach (var header_value in http_context.request.headers.get_or_empty(SLOT_HEADER)) {
  54. foreach (var part in header_value.split(",")) {
  55. var slot_value = part.strip();
  56. if (slot_value.length == 0) {
  57. continue;
  58. }
  59. HeldSlot? slot = null;
  60. if (slot_value.contains("key=")) {
  61. slot = resolve_reference(slot_value, result);
  62. if (slot == null || ((!)slot).type_name == null) {
  63. var key = parse_reference_key(slot_value);
  64. if (key != null) {
  65. result.transmit_keys.add((!)key);
  66. }
  67. continue;
  68. }
  69. } else {
  70. slot = resolve_frame(slot_value);
  71. if (slot == null || ((!)slot).type_name == null) {
  72. var signature = parse_frame_signature(slot_value);
  73. if (signature != null) {
  74. result.invalid_signatures.add((!)signature);
  75. }
  76. continue;
  77. }
  78. }
  79. result.held.set(((!)slot).type_name, (!)slot);
  80. }
  81. }
  82. return result;
  83. }
  84. /** Extracts the `key` from a `key=…; as_at=…` reference header value. */
  85. private static string? parse_reference_key(string slot_value) {
  86. foreach (var part in slot_value.split(";")) {
  87. var trimmed = part.strip();
  88. if (trimmed.has_prefix("key=")) {
  89. var key = trimmed.substring(4).strip();
  90. return key.length > 0 ? key : null;
  91. }
  92. }
  93. return null;
  94. }
  95. /**
  96. * Reads the `signature` off a base64 frame envelope WITHOUT trusting or
  97. * parsing its `content`, so an untrustworthy/corrupt frame can still be
  98. * reported for invalidation. Returns `null` if the envelope itself
  99. * cannot be parsed.
  100. */
  101. private static string? parse_frame_signature(string slot_value) {
  102. try {
  103. var bytes = Base64.decode(slot_value);
  104. var frame_json = to_null_terminated_string(bytes);
  105. var signature = new JsonElement.from_string(frame_json).as<JsonObject>().get_string("signature");
  106. return (signature != null && ((!)signature).length > 0) ? signature : null;
  107. } catch (GLib.Error e) {
  108. return null;
  109. }
  110. }
  111. private HeldSlot? resolve_reference(string header_value, ResolvedSlots result) {
  112. string? key = null;
  113. DateTime? as_at = null;
  114. foreach (var part in header_value.split(";")) {
  115. var trimmed = part.strip();
  116. if (trimmed.has_prefix("key=")) {
  117. key = trimmed.substring(4).strip();
  118. } else if (trimmed.has_prefix("as_at=")) {
  119. var raw = trimmed.substring(6).strip();
  120. as_at = new DateTime.from_iso8601(raw, new TimeZone.utc());
  121. }
  122. }
  123. if (key == null) {
  124. return null;
  125. }
  126. var slot = state_service.get_slot((!)key);
  127. if (slot == null || slot.current_state == null) {
  128. return null;
  129. }
  130. var state = slot.current_state;
  131. DateTime as_at_value = as_at;
  132. if (as_at_value == null) {
  133. if (state.last_touched != null) {
  134. as_at_value = state.last_touched;
  135. } else {
  136. as_at_value = new DateTime.now_utc();
  137. }
  138. }
  139. // Detect staleness: if the client's as_at predates the slot's
  140. // last_touched, the client holds an outdated snapshot.
  141. if (as_at != null && state.last_touched != null
  142. && ((!)as_at).compare(state.last_touched) < 0) {
  143. result.stale_keys.add(slot.id);
  144. }
  145. return new HeldSlot() {
  146. key = slot.id,
  147. type_name = slot.type_name,
  148. scope = slot.scope,
  149. @public = state.public_data ?? new PropertyDictionary(),
  150. @private = state.private_data ?? new PropertyDictionary(),
  151. as_at = as_at_value
  152. };
  153. }
  154. private HeldSlot? resolve_frame(string header_value) {
  155. string frame_json;
  156. try {
  157. var bytes = Base64.decode(header_value.strip());
  158. frame_json = to_null_terminated_string(bytes);
  159. } catch (GLib.Error e) {
  160. return null;
  161. }
  162. Model.FrameDto frame;
  163. Model.SnapshotDto snapshot;
  164. try {
  165. var frame_element = new JsonElement.from_string(frame_json);
  166. frame = Model.FrameDto.get_mapper().materialise(frame_element.as<JsonObject>());
  167. signing_provider.verify_frame(frame);
  168. var snapshot_element = new JsonElement.from_string(frame.content);
  169. snapshot = Model.SnapshotDto.get_mapper().materialise(snapshot_element.as<JsonObject>());
  170. } catch (GLib.Error e) {
  171. return null;
  172. }
  173. // Drop snapshots that have passed their invalid_after time.
  174. if (snapshot.invalid_after != null) {
  175. var now = new DateTime.now_utc();
  176. if (((!)snapshot.invalid_after).compare(now) < 0) {
  177. return null;
  178. }
  179. }
  180. var private_data = decrypt_private(snapshot);
  181. return new HeldSlot() {
  182. key = snapshot.slot.key,
  183. type_name = snapshot.slot.slot_type,
  184. scope = snapshot.slot.scope,
  185. @public = snapshot.@public ?? new PropertyDictionary(),
  186. @private = private_data,
  187. as_at = snapshot.as_at,
  188. transmit_after = snapshot.transmit_after,
  189. invalid_after = snapshot.invalid_after
  190. };
  191. }
  192. private Properties decrypt_private(Model.SnapshotDto snapshot) {
  193. if (snapshot.@private == null) {
  194. return new PropertyDictionary();
  195. }
  196. try {
  197. return encryption_provider.read_properties(snapshot.slot.key, (!)snapshot.@private);
  198. } catch (GLib.Error e) {
  199. return new PropertyDictionary();
  200. }
  201. }
  202. private static string to_null_terminated_string(uint8[] bytes) {
  203. var copy = new uint8[bytes.length + 1];
  204. Memory.copy(copy, bytes, bytes.length);
  205. return (string) copy;
  206. }
  207. }
  208. /**
  209. * The outcome of resolving a request's `X-Statum-Slot` headers.
  210. */
  211. public class ResolvedSlots : Object {
  212. /** Successfully resolved held slots, indexed by type name. */
  213. public Dictionary<string, HeldSlot> held { get; private set; default = new Dictionary<string, HeldSlot>(); }
  214. /**
  215. * Keys of reference-form slots no longer in the server's memory; the
  216. * caller emits a `transmit(retry)` for each so the client re-sends the
  217. * frame for the server to re-cache.
  218. */
  219. public Series<string> transmit_keys { get; private set; default = new Series<string>(); }
  220. /**
  221. * Signatures of frame-form slots that failed verification (or expired);
  222. * the caller emits an `invalidate` for each so the client drops the
  223. * matching held slot.
  224. */
  225. public Series<string> invalid_signatures { get; private set; default = new Series<string>(); }
  226. /**
  227. * Keys of cached slots whose client `as_at` predates the slot's
  228. * `last_touched`; the caller auto-signs and appends a `set` so the
  229. * client refreshes its stale snapshot.
  230. */
  231. public Series<string> stale_keys { get; private set; default = new Series<string>(); }
  232. }
  233. }