hecks 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (542) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/pizzas.bluebook +29 -0
  3. data/examples/pizzas/bluebook/pizzas.hecksagon +7 -0
  4. data/exe/hecks +4 -4
  5. data/lib/hecks/adapters/driven/d1.rb +2 -10
  6. data/lib/hecks/adapters/driven/heki.adapter +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +2 -12
  8. data/lib/hecks/adapters/driven/lambda.adapter +1 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +3 -18
  10. data/lib/hecks/adapters/driven/postgres.rb +4 -11
  11. data/lib/hecks/adapters/driven/sqlite.adapter +1 -1
  12. data/lib/hecks/adapters/driven/sqlite.rb +2 -10
  13. data/lib/hecks/adapters/driving/admission.rb +46 -0
  14. data/lib/hecks/adapters/driving/cli/nesting.rb +36 -0
  15. data/lib/hecks/adapters/driving/cli/shorthand.rb +88 -0
  16. data/lib/hecks/adapters/driving/cli.rb +127 -0
  17. data/lib/hecks/adapters/driving/cli_runner/answers.rb +160 -0
  18. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +158 -0
  19. data/lib/hecks/adapters/driving/cli_runner/settling.rb +120 -0
  20. data/lib/hecks/adapters/driving/cli_runner/streaming.rb +93 -0
  21. data/lib/hecks/adapters/driving/cli_runner/suggestions.rb +47 -0
  22. data/lib/hecks/adapters/driving/cli_runner.rb +70 -0
  23. data/lib/hecks/adapters/driving/command_request.rb +121 -0
  24. data/lib/hecks/adapters/driving/handle.rb +214 -0
  25. data/lib/hecks/adapters/driving/json.rb +136 -0
  26. data/lib/hecks/adapters/driving/launcher_options.rb +267 -0
  27. data/lib/hecks/adapters/driving/mcp/properties.rb +49 -0
  28. data/lib/hecks/adapters/driving/mcp/tool_calls.rb +116 -0
  29. data/lib/hecks/adapters/driving/mcp/tool_definitions/auditing.rb +53 -0
  30. data/lib/hecks/adapters/driving/mcp/tool_definitions/calling.rb +71 -0
  31. data/lib/hecks/adapters/driving/mcp/tool_definitions/reading.rb +99 -0
  32. data/lib/hecks/adapters/driving/mcp/tool_definitions.rb +18 -0
  33. data/lib/hecks/adapters/driving/mcp.rb +159 -0
  34. data/lib/hecks/adapters/driving/mcp_guide_cache.rb +78 -0
  35. data/lib/hecks/adapters/driving/mcp_scope/argument_policy.rb +97 -0
  36. data/lib/hecks/adapters/driving/mcp_scope/env_problems.rb +69 -0
  37. data/lib/hecks/adapters/driving/mcp_scope/presenter.rb +83 -0
  38. data/lib/hecks/adapters/driving/mcp_scope.rb +213 -0
  39. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  40. data/lib/hecks/adapters/driving/ruby/aggregate_module.rb +171 -0
  41. data/lib/hecks/adapters/driving/ruby/chapter.rb +91 -0
  42. data/lib/hecks/adapters/driving/ruby.rb +83 -0
  43. data/lib/hecks/adapters/driving/usage_cache.rb +144 -0
  44. data/lib/hecks/{doors.rb → adapters/driving.rb} +10 -6
  45. data/lib/hecks/behaviors/expectations/boot.rb +1 -1
  46. data/lib/hecks/bench/ruby_runner.rb +1 -1
  47. data/lib/hecks/bluebook/ask_resolution.rb +130 -0
  48. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +8 -2
  49. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  50. data/lib/hecks/bluebook/assembly/contracts/rules.rb +4 -2
  51. data/lib/hecks/bluebook/assembly/contracts/structure.rb +3 -1
  52. data/lib/hecks/bluebook/assembly/marks.rb +4 -15
  53. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  54. data/lib/hecks/bluebook/attribute.rb +1 -1
  55. data/lib/hecks/bluebook/behaviour/command.rb +13 -8
  56. data/lib/hecks/bluebook/behaviour/policy.rb +30 -0
  57. data/lib/hecks/bluebook/capabilities/attribute_values.rb +72 -0
  58. data/lib/hecks/bluebook/capabilities.rb +91 -9
  59. data/lib/hecks/bluebook/domain_port.rb +16 -4
  60. data/lib/hecks/bluebook/dsl/adapter_builder.rb +18 -6
  61. data/lib/hecks/bluebook/dsl/binding_proxy.rb +13 -0
  62. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/computed_sources.rb +98 -0
  63. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/fan_out_rows.rb +42 -0
  64. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/projected_fields.rb +14 -2
  65. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/provisions.rb +69 -3
  66. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/with_projections.rb +17 -10
  67. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  69. data/lib/hecks/bluebook/dsl/command_builder/mutations.rb +8 -1
  70. data/lib/hecks/bluebook/dsl/command_builder/operands.rb +121 -0
  71. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -0
  72. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +10 -1
  73. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +45 -2
  74. data/lib/hecks/bluebook/dsl/policy_builder.rb +47 -10
  75. data/lib/hecks/bluebook/dsl/rule_reference.rb +7 -4
  76. data/lib/hecks/bluebook/dsl/world_builder.rb +1 -1
  77. data/lib/hecks/bluebook/expression/ast_json/emitters.rb +6 -0
  78. data/lib/hecks/bluebook/expression/ast_json.rb +9 -6
  79. data/lib/hecks/bluebook/expression/ast_reader.rb +14 -9
  80. data/lib/hecks/bluebook/expression/projection.json +36 -0
  81. data/lib/hecks/bluebook/expression/resolver/operations.rb +31 -0
  82. data/lib/hecks/bluebook/expression/resolver/parsing.rb +38 -5
  83. data/lib/hecks/bluebook/expression/resolver/scanning.rb +22 -5
  84. data/lib/hecks/bluebook/expression/resolver/values.rb +10 -0
  85. data/lib/hecks/bluebook/expression/resolver.rb +20 -1
  86. data/lib/hecks/bluebook/hecksagon.rb +23 -3
  87. data/lib/hecks/bluebook/lifecycle.rb +22 -3
  88. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +13 -1
  89. data/lib/hecks/bluebook/meta_validator/modes.rb +4 -1
  90. data/lib/hecks/bluebook/meta_validator/readings.rb +24 -1
  91. data/lib/hecks/bluebook/meta_validator/reconstruction/heads.rb +2 -1
  92. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  93. data/lib/hecks/bluebook/meta_validator/shapes.rb +5 -0
  94. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +1 -1
  95. data/lib/hecks/bluebook/meta_validator.rb +12 -12
  96. data/lib/hecks/bluebook/model_check/ask_checks.rb +71 -0
  97. data/lib/hecks/bluebook/model_check/policy_checks.rb +2 -1
  98. data/lib/hecks/bluebook/model_check/reference_checks.rb +70 -0
  99. data/lib/hecks/bluebook/model_check.rb +8 -2
  100. data/lib/hecks/bluebook/policy.rb +4 -2
  101. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  102. data/lib/hecks/bluebook/synthesizer.rb +2 -0
  103. data/lib/hecks/bluebook.rb +2 -0
  104. data/lib/hecks/cli/command_table.rb +1 -1
  105. data/lib/hecks/cli/console.rb +2 -1
  106. data/lib/hecks/cli/document.rb +1 -1
  107. data/lib/hecks/cli/launcher_words.rb +4 -4
  108. data/lib/hecks/cli/mcp.rb +4 -4
  109. data/lib/hecks/cli/project_cli/launcher_source.rb +2 -2
  110. data/lib/hecks/cli/project_cli/templates.rb +5 -5
  111. data/lib/hecks/cli/project_cli.rb +9 -6
  112. data/lib/hecks/cli/run.rb +2 -2
  113. data/lib/hecks/cli/smoke_http/checks.rb +4 -1
  114. data/lib/hecks/cli/smoke_http/transport.rb +3 -3
  115. data/lib/hecks/cli/smoke_http.rb +3 -0
  116. data/lib/hecks/codemod.rb +5 -2
  117. data/lib/hecks/corpus/routes.rb +7 -0
  118. data/lib/hecks/corpus/rust.rb +3 -2
  119. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -4
  120. data/lib/hecks/doc/reference/rendering.rb +6 -3
  121. data/lib/hecks/forms/app/commands.rb +8 -7
  122. data/lib/hecks/forms/app/queries.rb +2 -1
  123. data/lib/hecks/forms/app.rb +1 -1
  124. data/lib/hecks/forms/record_renderer.rb +1 -1
  125. data/lib/hecks/framework/bluebook/privacy.bluebook +11 -0
  126. data/lib/hecks/fuzzing/concurrent_dispatch.rb +4 -1
  127. data/lib/hecks/fuzzing/failure_triage.rb +2 -1
  128. data/lib/hecks/fuzzing/mutation/run.rb +11 -4
  129. data/lib/hecks/fuzzing/properties/catalog.rb +6 -4
  130. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +18 -11
  131. data/lib/hecks/fuzzing/properties/guards.rb +1 -1
  132. data/lib/hecks/fuzzing/self_consistency/saga_redelivery.rb +1 -1
  133. data/lib/hecks/fuzzing/self_consistency.rb +6 -2
  134. data/lib/hecks/fuzzing/shrinker.rb +3 -1
  135. data/lib/hecks/fuzzing/value_generator.rb +19 -5
  136. data/lib/hecks/gate/stages.yml +33 -7
  137. data/lib/hecks/grammar/evolve.rb +1 -1
  138. data/lib/hecks/grammar/expression_operators.json +372 -0
  139. data/lib/hecks/grammar/operators.rb +3 -1
  140. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +10 -10
  141. data/lib/hecks/hecks/adapters/codebase/inspection/rubycritic.rb +35 -0
  142. data/lib/hecks/hecks/adapters/codebase/inspection.rb +104 -0
  143. data/lib/hecks/hecks/adapters/codebase/publishing.rb +2 -1
  144. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +12 -2
  145. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +2 -1
  146. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +1 -1
  147. data/lib/hecks/hecks/adapters/cost_explorer.rb +8 -5
  148. data/lib/hecks/hecks/adapters/deploy_toolchain/statuses.rb +2 -1
  149. data/lib/hecks/hecks/adapters/finding_github.rb +13 -6
  150. data/lib/hecks/hecks/adapters/first_admin.rb +2 -2
  151. data/lib/hecks/hecks/adapters/github_rulesets.rb +6 -3
  152. data/lib/hecks/hecks/adapters/in_process_boot.rb +1 -1
  153. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +1 -1
  154. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +10 -20
  155. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +28 -5
  156. data/lib/hecks/hecks/adapters/local_files.rb +2 -2
  157. data/lib/hecks/hecks/adapters/rehearsal/audit_report.rb +107 -0
  158. data/lib/hecks/hecks/adapters/rehearsal/census.rb +71 -0
  159. data/lib/hecks/hecks/adapters/rehearsal/declaration.rb +133 -0
  160. data/lib/hecks/hecks/adapters/rehearsal/environment.rb +83 -0
  161. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +142 -0
  162. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +96 -0
  163. data/lib/hecks/hecks/adapters/rehearsal/postgres_census.rb +100 -0
  164. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +69 -0
  165. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +133 -0
  166. data/lib/hecks/hecks/adapters/rehearsal/rows.rb +41 -0
  167. data/lib/hecks/hecks/adapters/rehearsal/rust_audit.rb +83 -0
  168. data/lib/hecks/hecks/adapters/rehearsal/subject.rb +104 -0
  169. data/lib/hecks/hecks/adapters/rehearsal.rb +54 -0
  170. data/lib/hecks/hecks/adapters/rehearsal_store.adapter +7 -0
  171. data/lib/hecks/hecks/adapters/rehearsal_store.rb +81 -0
  172. data/lib/hecks/hecks/adapters/terminal.rb +13 -11
  173. data/lib/hecks/hecks/codebase.behaviors +13 -1
  174. data/lib/hecks/hecks/codebase.bluebook +239 -5
  175. data/lib/hecks/hecks/custodian.behaviors +24 -6
  176. data/lib/hecks/hecks/custodian.bluebook +6 -1227
  177. data/lib/hecks/hecks/deploy.behaviors +1 -1
  178. data/lib/hecks/hecks/deploy.world +1 -3
  179. data/lib/hecks/hecks/hecks.behaviors +1 -1
  180. data/lib/hecks/hecks/hecks.hecksagon +53 -18
  181. data/lib/hecks/hecks/hecks.world +13 -10
  182. data/lib/hecks/hecks/rehearsal.bluebook +477 -0
  183. data/lib/hecks/hecks/site.behaviors +1 -1
  184. data/lib/hecks/hecks/site.world +0 -1
  185. data/lib/hecks/hecks/tooling.bluebook +1238 -0
  186. data/lib/hecks/indifferent_key.rb +20 -0
  187. data/lib/hecks/language/adapter.bluebook +13 -3
  188. data/lib/hecks/language/bluebook/aggregate.bluebook +23 -0
  189. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  190. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -0
  191. data/lib/hecks/language/bluebook/entity.bluebook +18 -0
  192. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  193. data/lib/hecks/language/bluebook/policy.bluebook +9 -1
  194. data/lib/hecks/language/bluebook/query.bluebook +1 -1
  195. data/lib/hecks/language/bluebook/vocabulary.bluebook +63 -4
  196. data/lib/hecks/language/hecksagon/domain_port.bluebook +1 -1
  197. data/lib/hecks/language/hecksagon/hecksagon.bluebook +22 -0
  198. data/lib/hecks/language/oidc.json +10 -0
  199. data/lib/hecks/language/port.bluebook +4 -4
  200. data/lib/hecks/literal.rb +12 -3
  201. data/lib/hecks/ports/key_vault.rb +7 -2
  202. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +21 -10
  203. data/lib/hecks/ports/persistence/plugins/era/postgres_era/boot.rb +4 -2
  204. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +2 -1
  205. data/lib/hecks/ports/persistence/repository_factory.rb +6 -3
  206. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  207. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  208. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  209. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  210. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  211. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  212. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  213. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  214. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  215. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  216. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  217. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  218. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  219. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +32 -0
  220. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +1 -1
  221. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  222. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  223. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  224. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  225. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  226. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  227. data/lib/hecks/projections/deploy/box.rb +15 -3
  228. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  229. data/lib/hecks/projections/deploy/shared.rb +5 -2
  230. data/lib/hecks/projections/model/deviations.rb +2 -2
  231. data/lib/hecks/projections/model.rb +2 -2
  232. data/lib/hecks/projections/oidc.rb +13 -1
  233. data/lib/hecks/projections/shape.rb +5 -2
  234. data/lib/hecks/projections/site/cms_editor/attributes.rb +26 -6
  235. data/lib/hecks/projections/site/cms_editor/blocks.rb +157 -0
  236. data/lib/hecks/projections/site/cms_editor/checks.rb +14 -1
  237. data/lib/hecks/projections/site/cms_editor/clearing.rb +70 -0
  238. data/lib/hecks/projections/site/cms_editor/color.rb +124 -0
  239. data/lib/hecks/projections/site/cms_editor/composition.rb +42 -0
  240. data/lib/hecks/projections/site/cms_editor/destructive.rb +61 -0
  241. data/lib/hecks/projections/site/cms_editor/drafts.rb +102 -0
  242. data/lib/hecks/projections/site/cms_editor/hints.rb +74 -0
  243. data/lib/hecks/projections/site/cms_editor/icons.rb +86 -0
  244. data/lib/hecks/projections/site/cms_editor/key_kinds.rb +31 -0
  245. data/lib/hecks/projections/site/cms_editor/limits.rb +72 -0
  246. data/lib/hecks/projections/site/cms_editor/media.rb +1 -1
  247. data/lib/hecks/projections/site/cms_editor/moment.rb +34 -0
  248. data/lib/hecks/projections/site/cms_editor/palette.rb +115 -0
  249. data/lib/hecks/projections/site/cms_editor/pickers.rb +85 -0
  250. data/lib/hecks/projections/site/cms_editor/preview.rb +91 -0
  251. data/lib/hecks/projections/site/cms_editor/relations.rb +92 -0
  252. data/lib/hecks/projections/site/cms_editor/scale_checks.rb +66 -0
  253. data/lib/hecks/projections/site/cms_editor/scheduling.rb +150 -0
  254. data/lib/hecks/projections/site/cms_editor/schema.rb +96 -19
  255. data/lib/hecks/projections/site/cms_editor/setting.rb +52 -9
  256. data/lib/hecks/projections/site/cms_editor/targets.rb +86 -0
  257. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +18 -1
  258. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +251 -57
  259. data/lib/hecks/projections/site/cms_editor/templates/src/assets.ts.tmpl +98 -0
  260. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +32 -24
  261. data/lib/hecks/projections/site/cms_editor/templates/src/browser/app.css.tmpl +412 -0
  262. data/lib/hecks/projections/site/cms_editor/templates/src/browser/autosave.js.tmpl +144 -0
  263. data/lib/hecks/projections/site/cms_editor/templates/src/browser/blocks.js.tmpl +500 -0
  264. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_doc.js.tmpl +175 -0
  265. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_editor.js.tmpl +418 -0
  266. data/lib/hecks/projections/site/cms_editor/templates/src/browser/epoch.js.tmpl +61 -0
  267. data/lib/hecks/projections/site/cms_editor/templates/src/browser/forms.js.tmpl +210 -0
  268. data/lib/hecks/projections/site/cms_editor/templates/src/browser/lists.js.tmpl +40 -0
  269. data/lib/hecks/projections/site/cms_editor/templates/src/browser/main.js.tmpl +26 -0
  270. data/lib/hecks/projections/site/cms_editor/templates/src/browser/media_picker.js.tmpl +232 -0
  271. data/lib/hecks/projections/site/cms_editor/templates/src/browser/moments.js.tmpl +73 -0
  272. data/lib/hecks/projections/site/cms_editor/templates/src/browser/pickers.js.tmpl +152 -0
  273. data/lib/hecks/projections/site/cms_editor/templates/src/browser/preview.js.tmpl +98 -0
  274. data/lib/hecks/projections/site/cms_editor/templates/src/browser/shell.js.tmpl +140 -0
  275. data/lib/hecks/projections/site/cms_editor/templates/src/browser/toast.js.tmpl +66 -0
  276. data/lib/hecks/projections/site/cms_editor/templates/src/choices.ts.tmpl +99 -0
  277. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +78 -24
  278. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +6 -0
  279. data/lib/hecks/projections/site/cms_editor/templates/src/flash.ts.tmpl +52 -0
  280. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +19 -2
  281. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +31 -8
  282. data/lib/hecks/projections/site/cms_editor/templates/src/names.ts.tmpl +36 -0
  283. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +131 -2
  284. data/lib/hecks/projections/site/cms_editor/templates/src/ui/availability.ts.tmpl +15 -0
  285. data/lib/hecks/projections/site/cms_editor/templates/src/ui/block_list.js.tmpl +213 -0
  286. data/lib/hecks/projections/site/cms_editor/templates/src/ui/blocks.ts.tmpl +275 -0
  287. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +8 -5
  288. data/lib/hecks/projections/site/cms_editor/templates/src/ui/detail.ts.tmpl +88 -0
  289. data/lib/hecks/projections/site/cms_editor/templates/src/ui/dialogs.ts.tmpl +43 -0
  290. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +229 -34
  291. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +40 -81
  292. data/lib/hecks/projections/site/cms_editor/templates/src/ui/icons.ts.tmpl +15 -0
  293. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +29 -12
  294. data/lib/hecks/projections/site/cms_editor/templates/src/ui/links.ts.tmpl +100 -0
  295. data/lib/hecks/projections/site/cms_editor/templates/src/ui/moments.ts.tmpl +44 -0
  296. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +53 -17
  297. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +178 -88
  298. data/lib/hecks/projections/site/cms_editor/templates/src/ui/paging.ts.tmpl +82 -0
  299. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pickers.ts.tmpl +110 -0
  300. data/lib/hecks/projections/site/cms_editor/templates/src/ui/preview.ts.tmpl +87 -0
  301. data/lib/hecks/projections/site/cms_editor/templates/src/ui/related.ts.tmpl +97 -0
  302. data/lib/hecks/projections/site/cms_editor/templates/src/ui/schedule.ts.tmpl +160 -0
  303. data/lib/hecks/projections/site/cms_editor/templates/src/ui/shell.ts.tmpl +211 -0
  304. data/lib/hecks/projections/site/cms_editor/templates/src/ui/table.ts.tmpl +151 -0
  305. data/lib/hecks/projections/site/cms_editor/templates/src/ui/theme.js.tmpl +16 -0
  306. data/lib/hecks/projections/site/cms_editor/templates/src/ui/values.ts.tmpl +69 -0
  307. data/lib/hecks/projections/site/cms_editor/templates/src/ui/words.ts.tmpl +97 -0
  308. data/lib/hecks/projections/site/cms_editor/templates/src/ui/world.ts.tmpl +35 -0
  309. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +2 -1
  310. data/lib/hecks/projections/site/cms_editor/theme.rb +29 -0
  311. data/lib/hecks/projections/site/cms_editor/theme_checks.rb +57 -0
  312. data/lib/hecks/projections/site/cms_editor.rb +49 -23
  313. data/lib/hecks/projections/site/role_probe.rb +2 -0
  314. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  315. data/lib/hecks/projector/cli_audience.rb +22 -1
  316. data/lib/hecks/projector/cli_projector/listing.rb +1 -1
  317. data/lib/hecks/projector/cli_projector/option_specs.rb +1 -1
  318. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  319. data/lib/hecks/projector/docs_projector/closing.rb +1 -1
  320. data/lib/hecks/projector/docs_projector.rb +1 -1
  321. data/lib/hecks/projector/exporter/bindings.rb +40 -10
  322. data/lib/hecks/projector/exporter/capability_values.rb +55 -0
  323. data/lib/hecks/projector/narrate_projector/reactions.rb +1 -1
  324. data/lib/hecks/projector/target.rb +11 -0
  325. data/lib/hecks/projector.rb +32 -5
  326. data/lib/hecks/quality_control/adapters/github_checks.rb +12 -8
  327. data/lib/hecks/quality_control/adapters/github_issues.rb +8 -4
  328. data/lib/hecks/quality_control/cli/qa_open_pr.rb +2 -2
  329. data/lib/hecks/quality_control/cli/qa_sweep/checks/shrinking.rb +1 -1
  330. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +1 -1
  331. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  332. data/lib/hecks/quality_control/quality_control.bluebook +6 -2
  333. data/lib/hecks/release/approval.rb +64 -0
  334. data/lib/hecks/router/{door.rb → aggregate_reader.rb} +5 -4
  335. data/lib/hecks/router/namespace_installer.rb +16 -16
  336. data/lib/hecks/router.rb +1 -1
  337. data/lib/hecks/runtime/best_effort.rb +22 -0
  338. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -2
  339. data/lib/hecks/runtime/command_interpreter.rb +3 -2
  340. data/lib/hecks/runtime/command_rules/emission.rb +1 -1
  341. data/lib/hecks/runtime/dispatcher.rb +4 -4
  342. data/lib/hecks/runtime/entity_element/location.rb +1 -1
  343. data/lib/hecks/runtime/event.rb +5 -0
  344. data/lib/hecks/runtime/identity.rb +7 -6
  345. data/lib/hecks/runtime/interpreting.rb +2 -1
  346. data/lib/hecks/runtime/invocation_builder.rb +1 -1
  347. data/lib/hecks/runtime/loader/boot_steps.rb +8 -5
  348. data/lib/hecks/runtime/loader.rb +10 -10
  349. data/lib/hecks/runtime/policy_interpreter/arguments.rb +7 -4
  350. data/lib/hecks/runtime/policy_interpreter/fan_out.rb +3 -3
  351. data/lib/hecks/runtime/policy_interpreter.rb +6 -6
  352. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -1
  353. data/lib/hecks/runtime/reaction_invocation.rb +1 -1
  354. data/lib/hecks/runtime/rebuild_sweep.rb +11 -1
  355. data/lib/hecks/runtime/registry/ask_verification.rb +35 -0
  356. data/lib/hecks/runtime/registry/hecksagon_rules.rb +10 -0
  357. data/lib/hecks/runtime/registry/merging.rb +1 -1
  358. data/lib/hecks/runtime/registry/providers.rb +16 -0
  359. data/lib/hecks/runtime/registry/read_repository.rb +6 -4
  360. data/lib/hecks/runtime/registry/verification.rb +4 -1
  361. data/lib/hecks/runtime/registry/world_defaults.rb +29 -0
  362. data/lib/hecks/runtime/registry.rb +1 -1
  363. data/lib/hecks/runtime/saga_interpreter/compensation.rb +1 -1
  364. data/lib/hecks/runtime/saga_interpreter/delivery.rb +4 -4
  365. data/lib/hecks/runtime/saga_interpreter/transitions.rb +1 -1
  366. data/lib/hecks/runtime/saga_interpreter.rb +3 -3
  367. data/lib/hecks/runtime/tenant_check.rb +5 -4
  368. data/lib/hecks/runtime/value/coercion.rb +2 -2
  369. data/lib/hecks/runtime/value/validation.rb +16 -4
  370. data/lib/hecks/runtime.rb +9 -9
  371. data/lib/hecks/rust_build/host.rb +11 -4
  372. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  373. data/lib/hecks/rust_build/project_rust/target_ir.rb +1 -1
  374. data/lib/hecks/rust_build/wasm.rb +1 -1
  375. data/lib/hecks/rust_build/wasm_browser.rb +1 -1
  376. data/lib/hecks/rust_build.rb +11 -0
  377. data/lib/hecks/served_options.rb +53 -0
  378. data/lib/hecks/site/bluebook/site.bluebook +1 -0
  379. data/lib/hecks/storehouse/audit.rb +1 -1
  380. data/lib/hecks/storehouse/dispatching.rb +8 -6
  381. data/lib/hecks/storehouse/introspection.rb +2 -2
  382. data/lib/hecks/storehouse/querying.rb +6 -4
  383. data/lib/hecks/storehouse.rb +4 -4
  384. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  385. data/lib/hecks/tools/ci_gates.rb +20 -1
  386. data/lib/hecks/tools/comment_style/source_file.rb +4 -1
  387. data/lib/hecks/tools/deploy_recipe_lint/fixtures.rb +4 -1
  388. data/lib/hecks/tools/lanes/files.rb +8 -0
  389. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  390. data/lib/hecks/tools/lanes/rows.rb +6 -0
  391. data/lib/hecks/tools/lanes.rb +1 -0
  392. data/lib/hecks/tools/oidc_manifests.rb +4 -5
  393. data/lib/hecks/tools/regeneration_run.rb +11 -4
  394. data/lib/hecks/tools/release_only.rb +111 -0
  395. data/lib/hecks/tools/site_routes/editor_files.rb +12 -5
  396. data/lib/hecks/tools/tenant_provisioning.rb +2 -2
  397. data/lib/hecks/tools/tools_doc.rb +4 -4
  398. data/lib/hecks/version.rb +1 -1
  399. data/lib/hecks/vocabulary.rb +19 -5
  400. data/lib/hecks.rb +15 -15
  401. data/lib/rubocop/cop/hecks/env_dump.rb +69 -0
  402. data/lib/rubocop/cop/hecks/monotonic_duration.rb +53 -0
  403. data/lib/rubocop/cop/hecks/no_wall_clock_in_runtime.rb +34 -0
  404. data/lib/rubocop/cop/hecks/raw_shell_interpolation.rb +79 -0
  405. data/lib/rubocop/cop/hecks/sleep_in_spec.rb +34 -0
  406. data/lib/rubocop/cop/hecks/swallowed_rescue.rb +37 -0
  407. data/lib/rubocop/cop/hecks/symbol_string_key_mix.rb +50 -0
  408. data/lib/rubocop/cop/hecks/untyped_raise.rb +37 -0
  409. data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
  410. data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
  411. data/prebuilt/verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json +1 -0
  412. data/rust/.gitignore +6 -0
  413. data/rust/Cargo.toml +6 -0
  414. data/rust/build/src/binding_pass.rs +103 -0
  415. data/rust/build/src/cargo_sync.rs +6 -0
  416. data/rust/build/src/lineage_pass.rs +3 -3
  417. data/rust/build/src/main.rs +17 -5
  418. data/rust/build/src/pipeline.rs +48 -5
  419. data/rust/build/src/seams_pass.rs +123 -0
  420. data/rust/build/src/translation_pass.rs +167 -0
  421. data/rust/codegen/src/asks.rs +103 -0
  422. data/rust/codegen/src/commands.rs +13 -8
  423. data/rust/codegen/src/constraints.rs +25 -0
  424. data/rust/codegen/src/domain_generator.rs +2 -0
  425. data/rust/codegen/src/expr_emitter.rs +13 -0
  426. data/rust/codegen/src/json_codec.rs +54 -15
  427. data/rust/codegen/src/main.rs +1 -0
  428. data/rust/codegen/src/mutations.rs +128 -3
  429. data/rust/codegen/src/queries.rs +11 -2
  430. data/rust/codegen/src/reactions.rs +1 -1
  431. data/rust/codegen/src/types.rs +166 -8
  432. data/rust/host/HECKS_RELEASE +1 -1
  433. data/rust/host/src/api.rs +4 -4
  434. data/rust/host/src/bin/validate_harness.rs +43 -0
  435. data/rust/host/src/boundary_fuzz/expr_json.rs +2 -2
  436. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  437. data/rust/host/src/boundary_fuzz/server.rs +1 -1
  438. data/rust/host/src/checkout.rs +35 -19
  439. data/rust/host/src/commerce_ir.rs +408 -2
  440. data/rust/host/src/expr_json.rs +118 -0
  441. data/rust/host/src/extension.rs +13 -1
  442. data/rust/host/src/lib.rs +1 -0
  443. data/rust/host/src/mint.rs +1 -1
  444. data/rust/host/src/payments/tests.rs +27 -1
  445. data/rust/host/src/payments.rs +12 -4
  446. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  447. data/rust/host/src/platform_signin/config.rs +512 -0
  448. data/rust/host/src/platform_signin/cookie.rs +237 -0
  449. data/rust/host/src/platform_signin/extension.rs +190 -0
  450. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  451. data/rust/host/src/platform_signin/handler.rs +251 -0
  452. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  453. data/rust/host/src/platform_signin/jti.rs +196 -0
  454. data/rust/host/src/platform_signin/jwks.rs +542 -0
  455. data/rust/host/src/platform_signin/jws.rs +249 -0
  456. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  457. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  458. data/rust/host/src/platform_signin/mod.rs +75 -0
  459. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  460. data/rust/host/src/platform_signin/pages.rs +204 -0
  461. data/rust/host/src/platform_signin/query.rs +233 -0
  462. data/rust/host/src/platform_signin/session.rs +151 -0
  463. data/rust/host/src/platform_signin/settings.rs +385 -0
  464. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  465. data/rust/host/src/platform_signin/test_support.rs +235 -0
  466. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  467. data/rust/host/src/platform_signin/verify.rs +604 -0
  468. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  469. data/rust/host/src/query_step.rs +22 -4
  470. data/rust/host/src/reference_validate.rs +653 -108
  471. data/rust/host/src/resend.rs +2 -0
  472. data/rust/host/src/server.rs +19 -6
  473. data/rust/host/src/ui_schema.rs +132 -5
  474. data/rust/host/src/web/newsletter.rs +7 -8
  475. data/rust/host/src/web/newsletter_send.rs +38 -13
  476. data/rust/host/src/web/registrations.rs +229 -26
  477. data/rust/host/src/web.rs +53 -9
  478. data/rust/parser/src/emit.rs +38 -6
  479. data/rust/parser/src/expr/ast_json.rs +7 -1
  480. data/rust/parser/src/expr/resolver.rs +171 -6
  481. data/rust/parser/src/ir.rs +7 -0
  482. data/rust/parser/src/keywords.rs +24 -0
  483. data/rust/parser/src/main.rs +3 -0
  484. data/rust/parser/src/parse/aggregate.rs +2 -1
  485. data/rust/parser/src/parse/chapter.rs +2 -2
  486. data/rust/parser/src/parse/command.rs +2 -0
  487. data/rust/parser/src/parse/hecksagon.rs +5 -2
  488. data/rust/parser/src/parse/lifecycle.rs +81 -0
  489. data/rust/parser/src/parse/mod.rs +15 -1
  490. data/rust/parser/src/parse/policy.rs +13 -2
  491. data/rust/parser/src/parse/value_object.rs +34 -17
  492. data/rust/parser/src/ruby_value.rs +1 -1
  493. data/rust/src/exemplar/commands.rs +4 -4
  494. data/rust/src/exemplar/constraints.rs +1 -1
  495. data/rust/src/exemplar/json.rs +43 -1
  496. data/rust/src/kernel/cli.rs +7 -5
  497. data/rust/src/kernel/dispatch.rs +2 -2
  498. data/rust/src/kernel/dispatch_guard_tests.rs +1 -1
  499. data/rust/src/kernel/expr.rs +66 -3
  500. data/rust/src/kernel/expression_operators/arithmetic.rs +130 -5
  501. data/rust/src/kernel/expression_operators/text.rs +26 -2
  502. data/rust/src/kernel/json.rs +15 -0
  503. data/rust/src/kernel/mod.rs +1 -1
  504. data/rust/src/kernel/orchestrate.rs +54 -5
  505. data/rust/src/kernel/orchestrate_guard_tests.rs +67 -0
  506. data/rust/src/kernel/pattern.rs +60 -0
  507. data/rust/src/kernel/reference_lookup.rs +90 -7
  508. metadata +178 -38
  509. data/lib/hecks/doors/cli_door/nesting.rb +0 -34
  510. data/lib/hecks/doors/cli_door/shorthand.rb +0 -85
  511. data/lib/hecks/doors/cli_door.rb +0 -124
  512. data/lib/hecks/doors/cli_runner/answers.rb +0 -138
  513. data/lib/hecks/doors/cli_runner/resolution.rb +0 -146
  514. data/lib/hecks/doors/cli_runner/settling.rb +0 -115
  515. data/lib/hecks/doors/cli_runner/streaming.rb +0 -90
  516. data/lib/hecks/doors/cli_runner/suggestions.rb +0 -43
  517. data/lib/hecks/doors/cli_runner.rb +0 -66
  518. data/lib/hecks/doors/command_request.rb +0 -119
  519. data/lib/hecks/doors/handle.rb +0 -208
  520. data/lib/hecks/doors/json_door.rb +0 -132
  521. data/lib/hecks/doors/launcher_options.rb +0 -247
  522. data/lib/hecks/doors/mcp_door/properties.rb +0 -47
  523. data/lib/hecks/doors/mcp_door/tool_calls.rb +0 -113
  524. data/lib/hecks/doors/mcp_door/tool_definitions/auditing.rb +0 -51
  525. data/lib/hecks/doors/mcp_door/tool_definitions/calling.rb +0 -69
  526. data/lib/hecks/doors/mcp_door/tool_definitions/reading.rb +0 -97
  527. data/lib/hecks/doors/mcp_door/tool_definitions.rb +0 -15
  528. data/lib/hecks/doors/mcp_door.rb +0 -156
  529. data/lib/hecks/doors/mcp_door_scope/argument_policy.rb +0 -92
  530. data/lib/hecks/doors/mcp_door_scope/env_problems.rb +0 -55
  531. data/lib/hecks/doors/mcp_door_scope/presenter.rb +0 -78
  532. data/lib/hecks/doors/mcp_door_scope.rb +0 -201
  533. data/lib/hecks/doors/mcp_guide_cache.rb +0 -75
  534. data/lib/hecks/doors/ruby_door/aggregate_door.rb +0 -162
  535. data/lib/hecks/doors/ruby_door/chapter.rb +0 -89
  536. data/lib/hecks/doors/ruby_door.rb +0 -80
  537. data/lib/hecks/doors/usage_cache.rb +0 -139
  538. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +0 -282
  539. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +0 -109
  540. data/prebuilt/c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal +0 -0
  541. data/prebuilt/cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal +0 -0
  542. data/prebuilt/verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json +0 -1
@@ -0,0 +1,1100 @@
1
+ //! Attacks on the verifier, each named for what the attacker tries.
2
+ //!
3
+ //! The first test is the positive control: a token this suite signed by hand must be accepted, so
4
+ //! every refusal below can only come from a verifier that tells the attack from a good token. The
5
+ //! examples mirror the issuer's own attack suite for the reference verifier.
6
+
7
+ use std::sync::atomic::{AtomicUsize, Ordering};
8
+ use std::sync::Arc;
9
+
10
+ use hmac::{Hmac, Mac};
11
+ use serde_json::{json, Value};
12
+ use sha2::Sha256;
13
+
14
+ use super::config::PlatformSigninConfig;
15
+ use super::jti::JtiStore;
16
+ use super::jwks::{JwksCache, KeySource};
17
+ use super::jws::{decode_base64url, encode_base64url};
18
+ use super::test_support::*;
19
+ use super::verify::{Reason, SigninRequest, Verifier};
20
+
21
+ fn refused_with(mutate: impl FnOnce(&mut Scenario)) -> Reason {
22
+ let mut scenario = Scenario::good();
23
+ mutate(&mut scenario);
24
+ scenario
25
+ .run(&verifier_for(&[&KEY_A]))
26
+ .expect_err("must be refused")
27
+ .reason
28
+ }
29
+
30
+ fn accepted_with(mutate: impl FnOnce(&mut Scenario)) {
31
+ let mut scenario = Scenario::good();
32
+ mutate(&mut scenario);
33
+ scenario
34
+ .run(&verifier_for(&[&KEY_A]))
35
+ .expect("must be accepted");
36
+ }
37
+
38
+ fn set(scenario: &mut Scenario, claim: &str, value: Value) {
39
+ scenario.claims.insert(claim.into(), value);
40
+ }
41
+
42
+ fn raw_token_reason(token: Option<&str>) -> Reason {
43
+ let verifier = verifier_for(&[&KEY_A]);
44
+ let cookie = good_cookie();
45
+ let request = SigninRequest {
46
+ token,
47
+ state: Some(STATE),
48
+ cookie: Some(&cookie),
49
+ };
50
+ verifier
51
+ .verify(&request, NOW)
52
+ .expect_err("must be refused")
53
+ .reason
54
+ }
55
+
56
+ // --- positive controls --------------------------------------------------------------------------
57
+
58
+ #[test]
59
+ fn accepts_a_valid_token() {
60
+ assert!(Scenario::good().run(&verifier_for(&[&KEY_A])).is_ok());
61
+ }
62
+
63
+ #[test]
64
+ fn accepts_a_token_exactly_at_the_edges_of_the_window() {
65
+ accepted_with(|s| set(s, "nbf", json!(NOW + 30)));
66
+ accepted_with(|s| {
67
+ set(s, "iat", json!(NOW - 89));
68
+ set(s, "nbf", json!(NOW - 89));
69
+ set(s, "exp", json!(NOW - 29));
70
+ });
71
+ }
72
+
73
+ // --- algorithm attacks -----------------------------------------------------------------------
74
+
75
+ #[test]
76
+ fn rejects_alg_none_with_and_without_a_signature_segment() {
77
+ let header = json!({"alg": "none", "typ": "JWT", "kid": KEY_A.kid()}).to_string();
78
+ let claims = Scenario::good().claims;
79
+ let body = encode_base64url(Value::Object(claims).to_string().as_bytes());
80
+ let head = encode_base64url(header.as_bytes());
81
+ for token in [
82
+ format!("{head}.{body}."),
83
+ format!("{head}.{body}.{}", encode_base64url(&[7u8; 64])),
84
+ ] {
85
+ assert_eq!(raw_token_reason(Some(&token)), Reason::BadAlg, "{token}");
86
+ }
87
+ for alg in ["None", "NONE", "nOnE", ""] {
88
+ let head = encode_base64url(
89
+ json!({"alg": alg, "typ": "JWT", "kid": KEY_A.kid()})
90
+ .to_string()
91
+ .as_bytes(),
92
+ );
93
+ assert_eq!(
94
+ raw_token_reason(Some(&format!("{head}.{body}."))),
95
+ Reason::BadAlg,
96
+ "{alg:?}"
97
+ );
98
+ }
99
+ }
100
+
101
+ #[test]
102
+ fn rejects_hs256_signed_with_the_public_key_as_the_hmac_secret() {
103
+ let public_raw = decode_base64url(X_A).unwrap();
104
+ let secrets: Vec<Vec<u8>> = vec![
105
+ public_raw,
106
+ X_A.as_bytes().to_vec(),
107
+ format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_A}"}}"#).into_bytes(),
108
+ ];
109
+ for secret in secrets {
110
+ let head = encode_base64url(
111
+ json!({"alg": "HS256", "typ": "JWT", "kid": KEY_A.kid()})
112
+ .to_string()
113
+ .as_bytes(),
114
+ );
115
+ let body = encode_base64url(
116
+ Value::Object(Scenario::good().claims)
117
+ .to_string()
118
+ .as_bytes(),
119
+ );
120
+ let input = format!("{head}.{body}");
121
+ let mut mac = Hmac::<Sha256>::new_from_slice(&secret).unwrap();
122
+ mac.update(input.as_bytes());
123
+ let token = format!("{input}.{}", encode_base64url(&mac.finalize().into_bytes()));
124
+ assert_eq!(raw_token_reason(Some(&token)), Reason::BadAlg);
125
+ }
126
+ }
127
+
128
+ #[test]
129
+ fn rejects_every_alg_other_than_eddsa_even_with_a_valid_eddsa_signature_over_that_header() {
130
+ for alg in [
131
+ "HS256", "HS384", "HS512", "RS256", "RS512", "ES256", "ES384", "PS256", "none", "eddsa",
132
+ "EDDSA", "Ed25519", "EdDSA ", "ES256K",
133
+ ] {
134
+ assert_eq!(
135
+ refused_with(|s| {
136
+ s.header.insert("alg".into(), json!(alg));
137
+ }),
138
+ Reason::BadAlg,
139
+ "{alg:?}"
140
+ );
141
+ }
142
+ assert_eq!(
143
+ refused_with(|s| {
144
+ s.header.remove("alg");
145
+ }),
146
+ Reason::BadAlg
147
+ );
148
+ assert_eq!(
149
+ refused_with(|s| {
150
+ s.header.insert("alg".into(), json!(["EdDSA"]));
151
+ }),
152
+ Reason::BadAlg
153
+ );
154
+ assert_eq!(
155
+ refused_with(|s| {
156
+ s.header.insert("alg".into(), Value::Null);
157
+ }),
158
+ Reason::BadAlg
159
+ );
160
+ }
161
+
162
+ #[test]
163
+ fn rejects_a_typ_that_is_not_jwt() {
164
+ for typ in [json!("jwt"), json!("JWS"), json!(""), json!(1), Value::Null] {
165
+ assert_eq!(
166
+ refused_with(|s| {
167
+ s.header.insert("typ".into(), typ.clone());
168
+ }),
169
+ Reason::Malformed,
170
+ "{typ}"
171
+ );
172
+ }
173
+ assert_eq!(
174
+ refused_with(|s| {
175
+ s.header.remove("typ");
176
+ }),
177
+ Reason::Malformed
178
+ );
179
+ }
180
+
181
+ #[test]
182
+ fn ignores_an_embedded_jwk_and_jku_and_never_uses_them() {
183
+ let attacker_jwk = json!({"kty": "OKP", "crv": "Ed25519", "x": X_B, "kid": KEY_B.kid()});
184
+ let reason = refused_with(|s| {
185
+ s.signer = KEY_B;
186
+ s.header.insert("jwk".into(), attacker_jwk.clone());
187
+ s.header
188
+ .insert("jku".into(), json!("https://evil.example/jwks.json"));
189
+ });
190
+ assert_eq!(
191
+ reason,
192
+ Reason::BadSignature,
193
+ "signed by the attacker, kid names the real key"
194
+ );
195
+ let reason = refused_with(|s| {
196
+ s.signer = KEY_B;
197
+ s.header.insert("kid".into(), json!(KEY_B.kid()));
198
+ s.header.insert("jwk".into(), attacker_jwk.clone());
199
+ });
200
+ assert_eq!(
201
+ reason,
202
+ Reason::UnknownKid,
203
+ "the attacker's own key is not in the key set"
204
+ );
205
+ }
206
+
207
+ #[test]
208
+ fn rejects_an_unknown_kid_a_missing_kid_and_a_kid_of_the_wrong_type() {
209
+ assert_eq!(
210
+ refused_with(|s| {
211
+ s.header.insert("kid".into(), json!("nope"));
212
+ }),
213
+ Reason::UnknownKid
214
+ );
215
+ assert_eq!(
216
+ refused_with(|s| {
217
+ s.header.remove("kid");
218
+ }),
219
+ Reason::UnknownKid
220
+ );
221
+ for kid in [json!(7), json!(["x"]), Value::Null, json!({"a": 1})] {
222
+ assert_eq!(
223
+ refused_with(|s| {
224
+ s.header.insert("kid".into(), kid.clone());
225
+ }),
226
+ Reason::UnknownKid
227
+ );
228
+ }
229
+ assert_eq!(
230
+ refused_with(|s| {
231
+ s.header.insert("kid".into(), json!(KID_A.to_uppercase()));
232
+ }),
233
+ Reason::UnknownKid
234
+ );
235
+ }
236
+
237
+ #[test]
238
+ fn rejects_a_token_signed_by_another_key_that_claims_the_real_kid() {
239
+ assert_eq!(refused_with(|s| s.signer = KEY_B), Reason::BadSignature);
240
+ }
241
+
242
+ // --- tampering -------------------------------------------------------------------------------
243
+
244
+ fn parts(scenario: &Scenario) -> (String, String, String) {
245
+ let token = scenario.token();
246
+ let mut it = token.split('.');
247
+ (
248
+ it.next().unwrap().into(),
249
+ it.next().unwrap().into(),
250
+ it.next().unwrap().into(),
251
+ )
252
+ }
253
+
254
+ #[test]
255
+ fn rejects_a_tampered_payload() {
256
+ let (head, _, signature) = parts(&Scenario::good());
257
+ let mut evil = Scenario::good().claims;
258
+ evil.insert("email".into(), json!("admin@example.test"));
259
+ evil.insert("sub".into(), json!("admin@example.test"));
260
+ let tampered = format!(
261
+ "{head}.{}.{signature}",
262
+ encode_base64url(Value::Object(evil).to_string().as_bytes())
263
+ );
264
+ assert_eq!(raw_token_reason(Some(&tampered)), Reason::BadSignature);
265
+ }
266
+
267
+ #[test]
268
+ fn rejects_a_tampered_header_with_the_kid_swapped_to_another_known_kid() {
269
+ let verifier = verifier_for(&[&KEY_A, &KEY_B]);
270
+ let (_, body, signature) = parts(&Scenario::good());
271
+ let head = encode_base64url(
272
+ json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_B.kid()})
273
+ .to_string()
274
+ .as_bytes(),
275
+ );
276
+ let cookie = good_cookie();
277
+ let token = format!("{head}.{body}.{signature}");
278
+ let request = SigninRequest {
279
+ token: Some(&token),
280
+ state: Some(STATE),
281
+ cookie: Some(&cookie),
282
+ };
283
+ assert_eq!(
284
+ verifier.verify(&request, NOW).unwrap_err().reason,
285
+ Reason::BadSignature
286
+ );
287
+ }
288
+
289
+ #[test]
290
+ fn rejects_a_tampered_signature_one_bit_flipped_and_every_wrong_length() {
291
+ assert_eq!(
292
+ refused_with(|s| s.flip_signature = true),
293
+ Reason::BadSignature
294
+ );
295
+ let (head, body, signature) = parts(&Scenario::good());
296
+ let bytes = decode_base64url(&signature).unwrap();
297
+ for wrong in [&bytes[..63], &bytes[..32], &bytes[..1], &[][..]] {
298
+ let token = format!("{head}.{body}.{}", encode_base64url(wrong));
299
+ assert_eq!(
300
+ raw_token_reason(Some(&token)),
301
+ Reason::BadSignature,
302
+ "{} bytes",
303
+ wrong.len()
304
+ );
305
+ }
306
+ let mut longer = bytes.clone();
307
+ longer.extend_from_slice(&[0, 0]);
308
+ assert_eq!(
309
+ raw_token_reason(Some(&format!(
310
+ "{head}.{body}.{}",
311
+ encode_base64url(&longer)
312
+ ))),
313
+ Reason::BadSignature
314
+ );
315
+ }
316
+
317
+ // --- structure -------------------------------------------------------------------------------
318
+
319
+ #[test]
320
+ fn rejects_truncated_extra_segment_empty_and_missing_tokens() {
321
+ let token = Scenario::good().token();
322
+ let (head, body, signature) = parts(&Scenario::good());
323
+ assert_eq!(
324
+ raw_token_reason(Some(&token[..token.len() - 10])),
325
+ Reason::BadSignature,
326
+ "a shorter signature still parses"
327
+ );
328
+ assert_eq!(
329
+ raw_token_reason(Some(&format!("{head}.{body}"))),
330
+ Reason::Malformed
331
+ );
332
+ assert_eq!(
333
+ raw_token_reason(Some(&format!("{token}.{signature}"))),
334
+ Reason::Malformed
335
+ );
336
+ assert_eq!(
337
+ raw_token_reason(Some(&format!("{token}."))),
338
+ Reason::Malformed
339
+ );
340
+ assert_eq!(raw_token_reason(Some("")), Reason::Malformed);
341
+ assert_eq!(raw_token_reason(Some("..")), Reason::Malformed);
342
+ assert_eq!(raw_token_reason(None), Reason::Malformed);
343
+ }
344
+
345
+ #[test]
346
+ fn rejects_non_base64url_segments_and_non_object_json() {
347
+ let (head, body, signature) = parts(&Scenario::good());
348
+ let cases = [
349
+ format!("{head}=.{body}.{signature}"),
350
+ format!("{head}.{}+.{signature}", &body[..body.len() - 1]),
351
+ format!("{head}./{body}.{signature}"),
352
+ format!("{head}.{} {}.{signature}", &body[..4], &body[4..]),
353
+ format!("{head}.{body}\n.{signature}"),
354
+ format!("{}.{body}.{signature}", encode_base64url(b"not json")),
355
+ format!("{head}.{}.{signature}", encode_base64url(b"[1,2]")),
356
+ format!("{head}.{}.{signature}", encode_base64url(b"\"s\"")),
357
+ format!("{head}.{}.{signature}", encode_base64url(b"")),
358
+ format!("{head}.{body}.{}", signature.replace(&signature[..1], "!")),
359
+ ];
360
+ for token in cases {
361
+ let reason = raw_token_reason(Some(&token));
362
+ assert_eq!(reason, Reason::Malformed, "{token}");
363
+ }
364
+ }
365
+
366
+ #[test]
367
+ fn rejects_a_header_or_payload_that_repeats_a_key() {
368
+ let kid = KEY_A.kid();
369
+ let claims = Value::Object(Scenario::good().claims).to_string();
370
+ let header_dup = format!(r#"{{"alg":"none","alg":"EdDSA","typ":"JWT","kid":"{kid}"}}"#);
371
+ let payload_dup = format!(r#"{{"aud":"other",{}"#, &claims[1..]);
372
+ assert_eq!(
373
+ raw_token_reason(Some(&assemble(&header_dup, &claims, &KEY_A))),
374
+ Reason::Malformed
375
+ );
376
+ let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}).to_string();
377
+ assert_eq!(
378
+ raw_token_reason(Some(&assemble(&header, &payload_dup, &KEY_A))),
379
+ Reason::Malformed
380
+ );
381
+ }
382
+
383
+ #[test]
384
+ fn rejects_a_non_canonical_signature_spelling() {
385
+ let (head, body, signature) = parts(&Scenario::good());
386
+ let last = signature.chars().last().unwrap();
387
+ let spare = if last == 'A' { 'B' } else { 'A' };
388
+ let respelled = format!("{head}.{body}.{}{spare}", &signature[..signature.len() - 1]);
389
+ assert_eq!(raw_token_reason(Some(&respelled)), Reason::Malformed);
390
+ assert_eq!(
391
+ raw_token_reason(Some(&format!("{head}.{body}.{signature}="))),
392
+ Reason::Malformed
393
+ );
394
+ }
395
+
396
+ fn token_of_exactly(len: usize) -> String {
397
+ for pad in 0..len {
398
+ for jti in 0..4 {
399
+ let mut scenario = Scenario::good();
400
+ set(&mut scenario, "pad", json!("x".repeat(pad)));
401
+ set(
402
+ &mut scenario,
403
+ "jti",
404
+ json!(format!("jti-size-{}", "j".repeat(jti))),
405
+ );
406
+ let token = scenario.token();
407
+ if token.len() == len {
408
+ return token;
409
+ }
410
+ }
411
+ }
412
+ panic!("no token of {len} bytes");
413
+ }
414
+
415
+ #[test]
416
+ fn rejects_an_oversized_token_before_parsing_it_and_accepts_one_at_the_cap() {
417
+ assert_eq!(
418
+ raw_token_reason(Some(&token_of_exactly(4097))),
419
+ Reason::TooLarge
420
+ );
421
+ assert_eq!(
422
+ raw_token_reason(Some(&"x".repeat(100_000))),
423
+ Reason::TooLarge
424
+ );
425
+ let at_cap = token_of_exactly(4096);
426
+ let cookie = good_cookie();
427
+ let request = SigninRequest {
428
+ token: Some(&at_cap),
429
+ state: Some(STATE),
430
+ cookie: Some(&cookie),
431
+ };
432
+ assert!(verifier_for(&[&KEY_A]).verify(&request, NOW).is_ok());
433
+ }
434
+
435
+ // --- claims ----------------------------------------------------------------------------------
436
+
437
+ #[test]
438
+ fn rejects_claims_of_the_wrong_json_type() {
439
+ assert_eq!(
440
+ refused_with(|s| set(s, "exp", json!("1800000060"))),
441
+ Reason::Malformed
442
+ );
443
+ assert_eq!(
444
+ refused_with(|s| set(s, "exp", json!(1800000060.0))),
445
+ Reason::Malformed
446
+ );
447
+ assert_eq!(
448
+ refused_with(|s| set(s, "iat", json!(1800000000.5))),
449
+ Reason::Malformed
450
+ );
451
+ assert_eq!(
452
+ refused_with(|s| set(s, "nbf", json!(true))),
453
+ Reason::Malformed
454
+ );
455
+ assert_eq!(
456
+ refused_with(|s| set(s, "aud", json!([SITE]))),
457
+ Reason::BadAud
458
+ );
459
+ assert_eq!(
460
+ refused_with(|s| set(s, "env", json!([ENVIRONMENT]))),
461
+ Reason::BadEnv
462
+ );
463
+ assert_eq!(
464
+ refused_with(|s| set(s, "iss", json!([ISSUER]))),
465
+ Reason::BadIss
466
+ );
467
+ }
468
+
469
+ #[test]
470
+ fn rejects_the_wrong_iss_including_lookalikes() {
471
+ for iss in [
472
+ "https://issuer.example/",
473
+ "http://issuer.example",
474
+ "https://ISSUER.example",
475
+ "https://issuer.example.evil.example",
476
+ "https://evil.example",
477
+ "issuer.example",
478
+ "",
479
+ ] {
480
+ assert_eq!(
481
+ refused_with(|s| set(s, "iss", json!(iss))),
482
+ Reason::BadIss,
483
+ "{iss:?}"
484
+ );
485
+ }
486
+ }
487
+
488
+ #[test]
489
+ fn rejects_the_wrong_aud_and_env() {
490
+ for aud in ["site-b", "SITE-A", "site-a ", "site", "platform", ""] {
491
+ assert_eq!(
492
+ refused_with(|s| set(s, "aud", json!(aud))),
493
+ Reason::BadAud,
494
+ "{aud:?}"
495
+ );
496
+ }
497
+ for env in ["staging", "Production", "prod", "production ", ""] {
498
+ assert_eq!(
499
+ refused_with(|s| set(s, "env", json!(env))),
500
+ Reason::BadEnv,
501
+ "{env:?}"
502
+ );
503
+ }
504
+ }
505
+
506
+ #[test]
507
+ fn rejects_an_expired_token_at_exp_minus_skew_and_not_a_second_sooner() {
508
+ assert_eq!(
509
+ refused_with(|s| {
510
+ set(s, "iat", json!(NOW - 90));
511
+ set(s, "nbf", json!(NOW - 90));
512
+ set(s, "exp", json!(NOW - 30));
513
+ }),
514
+ Reason::Expired
515
+ );
516
+ assert_eq!(
517
+ refused_with(|s| {
518
+ set(s, "iat", json!(NOW - 9000));
519
+ set(s, "nbf", json!(NOW - 9000));
520
+ set(s, "exp", json!(NOW - 8940));
521
+ }),
522
+ Reason::Expired
523
+ );
524
+ }
525
+
526
+ #[test]
527
+ fn rejects_a_not_yet_valid_token_beyond_the_skew() {
528
+ assert_eq!(
529
+ refused_with(|s| set(s, "nbf", json!(NOW + 31))),
530
+ Reason::NotYetValid
531
+ );
532
+ assert_eq!(
533
+ refused_with(|s| {
534
+ set(s, "nbf", json!(NOW + 3600));
535
+ set(s, "iat", json!(NOW + 3600));
536
+ set(s, "exp", json!(NOW + 3660));
537
+ }),
538
+ Reason::NotYetValid
539
+ );
540
+ }
541
+
542
+ #[test]
543
+ fn rejects_a_lifetime_over_sixty_seconds_even_when_currently_valid() {
544
+ assert_eq!(
545
+ refused_with(|s| set(s, "exp", json!(NOW + 61))),
546
+ Reason::Lifetime
547
+ );
548
+ assert_eq!(
549
+ refused_with(|s| set(s, "iat", json!(NOW - 1))),
550
+ Reason::Lifetime
551
+ );
552
+ assert_eq!(
553
+ refused_with(|s| set(s, "exp", json!(NOW))),
554
+ Reason::Lifetime,
555
+ "zero lifetime"
556
+ );
557
+ assert_eq!(
558
+ refused_with(|s| set(s, "exp", json!(NOW - 1))),
559
+ Reason::Lifetime,
560
+ "negative lifetime"
561
+ );
562
+ accepted_with(|s| set(s, "exp", json!(NOW + 1)));
563
+ }
564
+
565
+ #[test]
566
+ fn rejects_a_token_missing_any_required_claim_or_holding_null() {
567
+ for claim in [
568
+ "iss",
569
+ "aud",
570
+ "env",
571
+ "sub",
572
+ "email",
573
+ "email_verified",
574
+ "nonce",
575
+ "iat",
576
+ "nbf",
577
+ "exp",
578
+ "jti",
579
+ ] {
580
+ assert_eq!(
581
+ refused_with(|s| {
582
+ s.claims.remove(claim);
583
+ }),
584
+ Reason::MissingClaim,
585
+ "{claim}"
586
+ );
587
+ assert_eq!(
588
+ refused_with(|s| set(s, claim, Value::Null)),
589
+ Reason::MissingClaim,
590
+ "null {claim}"
591
+ );
592
+ }
593
+ accepted_with(|s| {
594
+ s.claims.remove("roles");
595
+ s.claims.remove("idp_iss");
596
+ s.claims.remove("idp_sub");
597
+ });
598
+ }
599
+
600
+ // --- single use, nonce, state ----------------------------------------------------------------
601
+
602
+ #[test]
603
+ fn accepts_a_jti_once_and_rejects_the_replay() {
604
+ let verifier = verifier_for(&[&KEY_A]);
605
+ let scenario = Scenario::good();
606
+ assert!(scenario.run(&verifier).is_ok());
607
+ assert_eq!(scenario.run(&verifier).unwrap_err().reason, Reason::Replay);
608
+ assert_eq!(scenario.run(&verifier).unwrap_err().reason, Reason::Replay);
609
+ }
610
+
611
+ #[test]
612
+ fn does_not_burn_the_jti_when_an_earlier_check_fails() {
613
+ let verifier = verifier_for(&[&KEY_A]);
614
+ let mut attacker = Scenario::good();
615
+ attacker.cookie = Some(super::verify::SigninCookie {
616
+ nonce: "attackers-own-nonce-value".into(),
617
+ state: STATE.into(),
618
+ });
619
+ assert_eq!(
620
+ attacker.run(&verifier).unwrap_err().reason,
621
+ Reason::NonceMismatch
622
+ );
623
+ assert!(
624
+ Scenario::good().run(&verifier).is_ok(),
625
+ "the genuine browser still redeems it"
626
+ );
627
+ }
628
+
629
+ #[test]
630
+ fn records_the_jti_until_exp_plus_skew_and_not_before() {
631
+ let verifier = verifier_for(&[&KEY_A]);
632
+ Scenario::good().run(&verifier).unwrap();
633
+ let store = verifier.jti_store();
634
+ assert!(!store.claim("jti-scenario-0000000000", NOW + 200, NOW + 89));
635
+ assert!(store.claim("jti-scenario-0000000000", NOW + 200, NOW + 90));
636
+ }
637
+
638
+ #[test]
639
+ fn a_jti_that_is_not_a_string_is_a_replay_not_a_pass() {
640
+ assert_eq!(refused_with(|s| set(s, "jti", json!(5))), Reason::Replay);
641
+ assert_eq!(refused_with(|s| set(s, "jti", json!(""))), Reason::Replay);
642
+ }
643
+
644
+ #[test]
645
+ fn rejects_a_nonce_that_differs_from_the_cookie() {
646
+ let mut scenario = Scenario::good();
647
+ scenario.cookie.as_mut().unwrap().nonce = "x".repeat(20);
648
+ assert_eq!(
649
+ scenario.run(&verifier_for(&[&KEY_A])).unwrap_err().reason,
650
+ Reason::NonceMismatch
651
+ );
652
+ assert_eq!(
653
+ refused_with(|s| set(s, "nonce", json!(NONCE.to_lowercase()))),
654
+ Reason::NonceMismatch
655
+ );
656
+ assert_eq!(
657
+ refused_with(|s| set(s, "nonce", json!(7))),
658
+ Reason::NonceMismatch
659
+ );
660
+ }
661
+
662
+ #[test]
663
+ fn rejects_when_the_cookie_is_absent_or_lacks_a_value() {
664
+ assert_eq!(refused_with(|s| s.cookie = None), Reason::MissingCookie);
665
+ assert_eq!(
666
+ refused_with(|s| s.cookie.as_mut().unwrap().state = String::new()),
667
+ Reason::MissingCookie
668
+ );
669
+ assert_eq!(
670
+ refused_with(|s| s.cookie.as_mut().unwrap().nonce = String::new()),
671
+ Reason::MissingCookie
672
+ );
673
+ }
674
+
675
+ #[test]
676
+ fn rejects_a_state_that_differs_or_is_missing_or_empty() {
677
+ assert_eq!(
678
+ refused_with(|s| s.state = Some("other".into())),
679
+ Reason::StateMismatch
680
+ );
681
+ assert_eq!(refused_with(|s| s.state = None), Reason::StateMismatch);
682
+ assert_eq!(
683
+ refused_with(|s| s.state = Some(String::new())),
684
+ Reason::StateMismatch
685
+ );
686
+ assert_eq!(
687
+ refused_with(|s| s.state = Some(format!("{STATE} "))),
688
+ Reason::StateMismatch
689
+ );
690
+ }
691
+
692
+ #[test]
693
+ fn checks_state_before_it_looks_at_the_token_at_all() {
694
+ let verifier = verifier_for(&[&KEY_A]);
695
+ let cookie = good_cookie();
696
+ for token in [Some("garbage"), None, Some("")] {
697
+ let request = SigninRequest {
698
+ token,
699
+ state: Some("wrong"),
700
+ cookie: Some(&cookie),
701
+ };
702
+ assert_eq!(
703
+ verifier.verify(&request, NOW).unwrap_err().reason,
704
+ Reason::StateMismatch
705
+ );
706
+ }
707
+ }
708
+
709
+ // --- identity --------------------------------------------------------------------------------
710
+
711
+ #[test]
712
+ fn rejects_email_verified_false_and_a_non_boolean() {
713
+ for value in [
714
+ json!(false),
715
+ json!("true"),
716
+ json!(1),
717
+ json!([true]),
718
+ json!(""),
719
+ ] {
720
+ assert_eq!(
721
+ refused_with(|s| set(s, "email_verified", value.clone())),
722
+ Reason::EmailUnverified,
723
+ "{value}"
724
+ );
725
+ }
726
+ }
727
+
728
+ #[test]
729
+ fn handles_a_mixed_case_email_consistently() {
730
+ accepted_with(|s| set(s, "email", json!("Person@Example.TEST")));
731
+ assert_eq!(
732
+ refused_with(|s| {
733
+ set(s, "email", json!("Person@Example.TEST"));
734
+ set(s, "sub", json!("Person@Example.TEST"));
735
+ }),
736
+ Reason::SubjectMismatch
737
+ );
738
+ assert_eq!(
739
+ refused_with(|s| set(s, "sub", json!("other@example.test"))),
740
+ Reason::SubjectMismatch
741
+ );
742
+ }
743
+
744
+ #[test]
745
+ fn rejects_an_email_or_sub_that_is_not_a_string() {
746
+ assert_eq!(
747
+ refused_with(|s| set(s, "email", json!(["person@example.test"]))),
748
+ Reason::Malformed
749
+ );
750
+ assert_eq!(refused_with(|s| set(s, "sub", json!(7))), Reason::Malformed);
751
+ }
752
+
753
+ // --- another site, another environment ----------------------------------------------------------
754
+
755
+ fn verifier_pinned(site: &str, environment: &str) -> Verifier<StaticKeys> {
756
+ let config = PlatformSigninConfig::new(ISSUER, "https://issuer.example/k", site, environment);
757
+ Verifier::new(config, static_keys(&[&KEY_A]), JtiStore::new())
758
+ }
759
+
760
+ #[test]
761
+ fn a_token_for_site_a_presented_to_site_b_is_refused() {
762
+ assert_eq!(
763
+ Scenario::good()
764
+ .run(&verifier_pinned("site-b", ENVIRONMENT))
765
+ .unwrap_err()
766
+ .reason,
767
+ Reason::BadAud
768
+ );
769
+ }
770
+
771
+ #[test]
772
+ fn a_production_token_on_a_staging_site_is_refused() {
773
+ assert_eq!(
774
+ Scenario::good()
775
+ .run(&verifier_pinned(SITE, "staging"))
776
+ .unwrap_err()
777
+ .reason,
778
+ Reason::BadEnv
779
+ );
780
+ }
781
+
782
+ #[test]
783
+ fn a_verifier_pinned_to_another_issuer_refuses_this_issuers_tokens() {
784
+ let config = PlatformSigninConfig::new(
785
+ "https://other-issuer.example",
786
+ "https://other-issuer.example/k",
787
+ SITE,
788
+ ENVIRONMENT,
789
+ );
790
+ let verifier = Verifier::new(config, static_keys(&[&KEY_A]), JtiStore::new());
791
+ assert_eq!(
792
+ Scenario::good().run(&verifier).unwrap_err().reason,
793
+ Reason::BadIss
794
+ );
795
+ }
796
+
797
+ // --- keys through the cache ------------------------------------------------------------------
798
+
799
+ fn cached_verifier(documents: Vec<String>) -> (Verifier<JwksCache<FnFetcher>>, Arc<AtomicUsize>) {
800
+ let calls = Arc::new(AtomicUsize::new(0));
801
+ let seen = calls.clone();
802
+ let fetcher = FnFetcher(Box::new(move || {
803
+ let n = seen.fetch_add(1, Ordering::SeqCst);
804
+ Ok(documents[n.min(documents.len() - 1)].clone())
805
+ }));
806
+ (
807
+ Verifier::new(config(), JwksCache::new(fetcher), JtiStore::new()),
808
+ calls,
809
+ )
810
+ }
811
+
812
+ fn run_with<K: KeySource>(verifier: &Verifier<K>, scenario: &Scenario) -> Result<(), Reason> {
813
+ let token = scenario.token();
814
+ let request = SigninRequest {
815
+ token: Some(&token),
816
+ state: scenario.state.as_deref(),
817
+ cookie: scenario.cookie.as_ref(),
818
+ };
819
+ verifier
820
+ .verify(&request, scenario.now)
821
+ .map(|_| ())
822
+ .map_err(|r| r.reason)
823
+ }
824
+
825
+ #[test]
826
+ fn a_flood_of_unknown_kids_causes_one_fetch_not_one_per_token() {
827
+ let (verifier, calls) = cached_verifier(vec![jwks_document(&[&KEY_A])]);
828
+ for i in 0..100 {
829
+ let mut scenario = Scenario::good();
830
+ scenario
831
+ .header
832
+ .insert("kid".into(), json!(format!("random-kid-{i}")));
833
+ scenario.now = NOW + (i % 25);
834
+ assert_eq!(run_with(&verifier, &scenario), Err(Reason::UnknownKid));
835
+ }
836
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
837
+ }
838
+
839
+ #[test]
840
+ fn a_known_kid_does_not_refetch() {
841
+ let (verifier, calls) = cached_verifier(vec![jwks_document(&[&KEY_A])]);
842
+ for i in 0..5 {
843
+ let mut scenario = Scenario::good();
844
+ set(&mut scenario, "jti", json!(format!("jti-known-{i}")));
845
+ assert_eq!(run_with(&verifier, &scenario), Ok(()));
846
+ }
847
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
848
+ }
849
+
850
+ #[test]
851
+ fn a_previous_key_verifies_while_listed_and_is_refused_once_removed() {
852
+ let (verifier, _) = cached_verifier(vec![
853
+ jwks_document(&[&KEY_A, &KEY_B]),
854
+ jwks_document(&[&KEY_B]),
855
+ ]);
856
+ let mut old = Scenario::good();
857
+ old.signer = KEY_A;
858
+ assert_eq!(run_with(&verifier, &old), Ok(()));
859
+ let mut newer = Scenario::good();
860
+ newer.signer = KEY_B;
861
+ newer.header.insert("kid".into(), json!(KEY_B.kid()));
862
+ set(&mut newer, "jti", json!("jti-rotated-key-0001"));
863
+ assert_eq!(run_with(&verifier, &newer), Ok(()));
864
+ let mut later = Scenario::good();
865
+ later.now = NOW + 300;
866
+ set(&mut later, "iat", json!(NOW + 300));
867
+ set(&mut later, "nbf", json!(NOW + 300));
868
+ set(&mut later, "exp", json!(NOW + 360));
869
+ set(&mut later, "jti", json!("jti-after-removal-001"));
870
+ assert_eq!(run_with(&verifier, &later), Err(Reason::UnknownKid));
871
+ }
872
+
873
+ // --- the order of the checks -----------------------------------------------------------------
874
+
875
+ type Fault = fn(&mut Scenario);
876
+
877
+ fn missing_cookie(s: &mut Scenario) {
878
+ s.cookie = None;
879
+ }
880
+ fn state_mismatch(s: &mut Scenario) {
881
+ s.state = Some("wrong-state".into());
882
+ }
883
+ fn garbage_token(s: &mut Scenario) {
884
+ s.replace_token = Some("not.a.token".into());
885
+ }
886
+ fn too_large(s: &mut Scenario) {
887
+ set(s, "pad", json!("x".repeat(5000)));
888
+ }
889
+ fn bad_alg(s: &mut Scenario) {
890
+ s.header.insert("alg".into(), json!("none"));
891
+ }
892
+ fn bad_typ(s: &mut Scenario) {
893
+ s.header.insert("typ".into(), json!("JWS"));
894
+ }
895
+ fn unknown_kid(s: &mut Scenario) {
896
+ s.header.insert("kid".into(), json!("nope"));
897
+ }
898
+ fn bad_signature(s: &mut Scenario) {
899
+ s.flip_signature = true;
900
+ }
901
+ fn missing_claim(s: &mut Scenario) {
902
+ s.claims.remove("jti");
903
+ }
904
+ fn bad_iss(s: &mut Scenario) {
905
+ set(s, "iss", json!("https://evil.example"));
906
+ }
907
+ fn bad_aud(s: &mut Scenario) {
908
+ set(s, "aud", json!("site-b"));
909
+ }
910
+ fn bad_env(s: &mut Scenario) {
911
+ set(s, "env", json!("staging"));
912
+ }
913
+ fn float_exp(s: &mut Scenario) {
914
+ set(s, "exp", json!(1800000060.5));
915
+ }
916
+ fn not_yet_valid(s: &mut Scenario) {
917
+ set(s, "nbf", json!(NOW + 100));
918
+ }
919
+ fn expired(s: &mut Scenario) {
920
+ set(s, "iat", json!(NOW - 160));
921
+ set(s, "exp", json!(NOW - 100));
922
+ }
923
+ fn long_lifetime(s: &mut Scenario) {
924
+ set(s, "iat", json!(NOW - 100));
925
+ }
926
+ fn nonce_mismatch(s: &mut Scenario) {
927
+ set(s, "nonce", json!("someone-elses-nonce"));
928
+ }
929
+ fn replayed(s: &mut Scenario) {
930
+ s.spend_first = true;
931
+ }
932
+ fn unverified(s: &mut Scenario) {
933
+ set(s, "email_verified", json!(false));
934
+ }
935
+ fn wrong_subject(s: &mut Scenario) {
936
+ set(s, "sub", json!("other@example.test"));
937
+ }
938
+
939
+ fn with_both(first: Fault, second: Fault) -> (Reason, Reason) {
940
+ let run = |a: Fault, b: Fault| {
941
+ let mut scenario = Scenario::good();
942
+ a(&mut scenario);
943
+ b(&mut scenario);
944
+ scenario
945
+ .run(&verifier_for(&[&KEY_A]))
946
+ .expect_err("two faults")
947
+ .reason
948
+ };
949
+ (run(first, second), run(second, first))
950
+ }
951
+
952
+ #[test]
953
+ fn a_token_with_two_faults_reports_the_first_in_the_references_order() {
954
+ let pairs: [(Fault, Fault, Reason); 20] = [
955
+ (missing_cookie, state_mismatch, Reason::MissingCookie),
956
+ (missing_cookie, garbage_token, Reason::MissingCookie),
957
+ (state_mismatch, garbage_token, Reason::StateMismatch),
958
+ (state_mismatch, bad_signature, Reason::StateMismatch),
959
+ (too_large, bad_alg, Reason::TooLarge),
960
+ (bad_alg, bad_typ, Reason::BadAlg),
961
+ (bad_alg, unknown_kid, Reason::BadAlg),
962
+ (bad_typ, unknown_kid, Reason::Malformed),
963
+ (unknown_kid, bad_aud, Reason::UnknownKid),
964
+ (bad_signature, bad_aud, Reason::BadSignature),
965
+ (bad_signature, missing_claim, Reason::BadSignature),
966
+ (missing_claim, bad_iss, Reason::MissingClaim),
967
+ (bad_iss, bad_aud, Reason::BadIss),
968
+ (bad_aud, bad_env, Reason::BadAud),
969
+ (bad_env, float_exp, Reason::BadEnv),
970
+ (float_exp, not_yet_valid, Reason::Malformed),
971
+ (not_yet_valid, expired, Reason::NotYetValid),
972
+ (expired, long_lifetime, Reason::Expired),
973
+ (long_lifetime, nonce_mismatch, Reason::Lifetime),
974
+ (nonce_mismatch, replayed, Reason::NonceMismatch),
975
+ ];
976
+ for (first, second, expected) in pairs {
977
+ assert_eq!(
978
+ with_both(first, second),
979
+ (expected, expected),
980
+ "{expected:?} must win in either application order"
981
+ );
982
+ }
983
+ }
984
+
985
+ #[test]
986
+ fn the_identity_checks_come_last() {
987
+ assert_eq!(
988
+ with_both(replayed, unverified),
989
+ (Reason::Replay, Reason::Replay)
990
+ );
991
+ assert_eq!(
992
+ with_both(unverified, wrong_subject),
993
+ (Reason::EmailUnverified, Reason::EmailUnverified)
994
+ );
995
+ assert_eq!(
996
+ with_both(nonce_mismatch, unverified),
997
+ (Reason::NonceMismatch, Reason::NonceMismatch)
998
+ );
999
+ }
1000
+
1001
+ #[test]
1002
+ fn the_replay_check_spends_nothing_when_an_earlier_check_fails() {
1003
+ for fault in [
1004
+ state_mismatch,
1005
+ bad_alg,
1006
+ bad_signature,
1007
+ bad_aud,
1008
+ expired,
1009
+ nonce_mismatch,
1010
+ ] {
1011
+ let verifier = verifier_for(&[&KEY_A]);
1012
+ let mut scenario = Scenario::good();
1013
+ fault(&mut scenario);
1014
+ assert!(scenario.run(&verifier).is_err());
1015
+ assert!(
1016
+ verifier.jti_store().is_empty(NOW),
1017
+ "a refused request must not leave a spent jti"
1018
+ );
1019
+ }
1020
+ }
1021
+
1022
+ #[test]
1023
+ fn identity_failures_come_after_the_jti_is_spent() {
1024
+ let verifier = verifier_for(&[&KEY_A]);
1025
+ let mut scenario = Scenario::good();
1026
+ unverified(&mut scenario);
1027
+ assert_eq!(
1028
+ scenario.run(&verifier).unwrap_err().reason,
1029
+ Reason::EmailUnverified
1030
+ );
1031
+ assert_eq!(
1032
+ verifier.jti_store().len(NOW),
1033
+ 1,
1034
+ "the reference spends the jti before the identity checks"
1035
+ );
1036
+ }
1037
+
1038
+ // --- hostile input never panics --------------------------------------------------------------
1039
+
1040
+ #[test]
1041
+ fn every_single_character_mutation_of_a_good_token_is_answered_without_panicking() {
1042
+ let good = Scenario::good().token();
1043
+ let cookie = good_cookie();
1044
+ let replacements = ['A', '.', '=', '-', '_', ' ', '\u{0}', '\u{e9}', '\u{1f600}'];
1045
+ for position in (0..good.len()).step_by(7) {
1046
+ for replacement in replacements {
1047
+ if !good.is_char_boundary(position) {
1048
+ continue;
1049
+ }
1050
+ let mut mutated = good.clone();
1051
+ mutated.replace_range(position..position + 1, &replacement.to_string());
1052
+ let request = SigninRequest {
1053
+ token: Some(&mutated),
1054
+ state: Some(STATE),
1055
+ cookie: Some(&cookie),
1056
+ };
1057
+ let verdict = verifier_for(&[&KEY_A]).verify(&request, NOW);
1058
+ if mutated != good {
1059
+ assert!(
1060
+ verdict.is_err(),
1061
+ "a mutated token must never be accepted: position {position}"
1062
+ );
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ #[test]
1069
+ fn extreme_numbers_in_time_claims_are_refused_without_overflow() {
1070
+ let big = "99999999999999999999999999999999999999999999999";
1071
+ for claim in ["iat", "nbf", "exp"] {
1072
+ let claims = Scenario::good().claims;
1073
+ let mut text = Value::Object(claims).to_string();
1074
+ let needle = format!("\"{claim}\":");
1075
+ let start = text.find(&needle).unwrap() + needle.len();
1076
+ let end = start + text[start..].find([',', '}']).unwrap();
1077
+ text.replace_range(start..end, big);
1078
+ let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_A.kid()}).to_string();
1079
+ let token = assemble(&header, &text, &KEY_A);
1080
+ assert!(
1081
+ matches!(
1082
+ raw_token_reason(Some(&token)),
1083
+ Reason::NotYetValid | Reason::Lifetime | Reason::Expired
1084
+ ),
1085
+ "{claim}"
1086
+ );
1087
+ if claim == "nbf" {
1088
+ continue; // a far-past `nbf` is simply valid; only the other two have a negative extreme to refuse
1089
+ }
1090
+ let negative = text.replace(big, &format!("-{big}"));
1091
+ let token = assemble(&header, &negative, &KEY_A);
1092
+ assert!(
1093
+ matches!(
1094
+ raw_token_reason(Some(&token)),
1095
+ Reason::Expired | Reason::Lifetime
1096
+ ),
1097
+ "{claim}"
1098
+ );
1099
+ }
1100
+ }