clickwrap 0.0.0 → 0.1.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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
@@ -0,0 +1,521 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ class Capture
5
+ # Verifies the complete render-to-submit contract before Capture opens a
6
+ # database transaction. It owns no writes: its result is the frozen input
7
+ # the transaction is allowed to turn into an evidence event.
8
+ class PresentationVerifier
9
+ Result = Data.define(
10
+ :manifest,
11
+ :revision,
12
+ :statement_snapshots,
13
+ :answers,
14
+ :document_versions_by_id,
15
+ :capture_channel
16
+ )
17
+
18
+ def initialize(policy:, submission:, explicit_answers:, actor_reference:, tenant_key:,
19
+ subject_key:, subject_fingerprint:, represented_party:,
20
+ prospective_actor:, registration_flow_id:, explicit_capture_channel:,
21
+ represented_party_creation_flow_id: nil,
22
+ creating_represented_party: false)
23
+ @policy = policy
24
+ @submission = submission
25
+ @explicit_answers = explicit_answers
26
+ @actor_reference = actor_reference
27
+ @tenant_key = tenant_key
28
+ @subject_key = subject_key
29
+ @subject_fingerprint = subject_fingerprint
30
+ @represented_party = represented_party
31
+ @prospective_actor = prospective_actor
32
+ @registration_flow_id = registration_flow_id
33
+ @explicit_capture_channel = explicit_capture_channel
34
+ @represented_party_creation_flow_id = represented_party_creation_flow_id
35
+ @creating_represented_party = creating_represented_party == true
36
+ end
37
+
38
+ def verify!(for_replay: false)
39
+ manifest = submitted_manifest!
40
+ capture_channel = @explicit_capture_channel || manifest.capture_channel.to_s
41
+
42
+ verify_bindings!(manifest, capture_channel, for_replay: for_replay)
43
+ document_versions = verify_document_digests!(manifest)
44
+ revision = load_revision!(manifest, require_current_policy: !for_replay)
45
+ statement_snapshots = frozen_statement_snapshots(revision)
46
+ verify_manifest_matches_revision!(manifest, statement_snapshots)
47
+ verify_combined_control!(manifest, statement_snapshots)
48
+ answers = collect_answers(manifest)
49
+ validate_answers!(manifest, answers, statement_snapshots)
50
+
51
+ Result.new(
52
+ manifest: manifest,
53
+ revision: revision,
54
+ statement_snapshots: statement_snapshots.freeze,
55
+ answers: answers.freeze,
56
+ document_versions_by_id: document_versions.freeze,
57
+ capture_channel: capture_channel.freeze
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :policy, :submission
64
+
65
+ def submitted_manifest!
66
+ manifest = submission&.manifest
67
+
68
+ unless manifest
69
+ raise PresentationInvalid,
70
+ "This capture has no presentation. Render the policy with `form.clickwrap` or " \
71
+ "`Clickwrap.present`, and submit the token it produced."
72
+ end
73
+
74
+ if manifest.policy_key != policy.key
75
+ raise PresentationInvalid.new(
76
+ "The submitted presentation is for policy #{manifest.policy_key.inspect}, " \
77
+ "not #{policy.key.inspect}.",
78
+ result: Verification::Result.failure(:presentation_policy_mismatch, policy_key: policy.key)
79
+ )
80
+ end
81
+
82
+ if manifest.expired?
83
+ raise PresentationExpired.new(
84
+ "The presentation expired at #{manifest.expires_at}. Re-render the form so the person " \
85
+ "acts on something current.",
86
+ result: Verification::Result.failure(:presentation_expired, policy_key: policy.key)
87
+ )
88
+ end
89
+
90
+ manifest
91
+ end
92
+
93
+ def verify_bindings!(manifest, capture_channel, for_replay:)
94
+ verify_registration_binding!(manifest)
95
+ verify_actor_binding!(manifest)
96
+ verify_tenant_binding!(manifest)
97
+ verify_channel_binding!(manifest, capture_channel, for_replay: for_replay)
98
+ verify_subject_binding!(manifest, for_replay: for_replay)
99
+ verify_represented_party_binding!(manifest, for_replay: for_replay)
100
+ end
101
+
102
+ def verify_actor_binding!(manifest)
103
+ return if manifest.registration_flow_id.present?
104
+ return if manifest.actor_reference.present? && @actor_reference.present? &&
105
+ Digest.secure_compare?(manifest.actor_reference, @actor_reference)
106
+
107
+ raise PresentationInvalid.new(
108
+ "This presentation was issued to a different actor.",
109
+ result: Verification::Result.failure(:presentation_actor_mismatch, policy_key: policy.key)
110
+ )
111
+ end
112
+
113
+ def verify_tenant_binding!(manifest)
114
+ return if manifest.tenant_key.to_s == @tenant_key.to_s
115
+
116
+ raise PresentationInvalid.new(
117
+ "This presentation was issued for a different tenant.",
118
+ result: Verification::Result.failure(:presentation_tenant_mismatch, policy_key: policy.key)
119
+ )
120
+ end
121
+
122
+ def verify_channel_binding!(manifest, capture_channel, for_replay:)
123
+ unless manifest.capture_channel.to_s == capture_channel.to_s &&
124
+ (for_replay || policy.permits_capture_channel?(capture_channel))
125
+ raise PresentationInvalid.new(
126
+ "Policy #{policy.key} does not accept this presentation's capture channel.",
127
+ result: Verification::Result.failure(:presentation_channel_mismatch, policy_key: policy.key)
128
+ )
129
+ end
130
+ end
131
+
132
+ def verify_subject_binding!(manifest, for_replay:)
133
+ if manifest.subject_key.to_s != @subject_key.to_s
134
+ raise PresentationInvalid.new(
135
+ "This presentation was issued for a different subject.",
136
+ result: Verification::Result.failure(:presentation_subject_mismatch, policy_key: policy.key)
137
+ )
138
+ end
139
+
140
+ # The committed event's immutable manifest digest and subject
141
+ # fingerprint are checked by Capture#replay. Requiring the LIVE
142
+ # pre-action fingerprint here would reject a safe retry whenever the
143
+ # successful protected action itself changed that state.
144
+ return if for_replay
145
+
146
+ fingerprint_matches =
147
+ if manifest.subject_fingerprint.blank? && @subject_fingerprint.blank?
148
+ true
149
+ else
150
+ Digest.secure_compare?(manifest.subject_fingerprint.to_s, @subject_fingerprint.to_s)
151
+ end
152
+ return if fingerprint_matches
153
+
154
+ raise PresentationInvalid.new(
155
+ "The subject changed after this presentation was issued. Re-render it against the " \
156
+ "subject's current state before acting.",
157
+ result: Verification::Result.failure(:subject_fingerprint_mismatch, policy_key: policy.key)
158
+ )
159
+ end
160
+
161
+ def verify_represented_party_binding!(manifest, for_replay:)
162
+ if manifest.represented_party_will_be_created_by_protected_action?
163
+ return verify_represented_party_creation_binding!(manifest, for_replay: for_replay)
164
+ end
165
+
166
+ if @creating_represented_party
167
+ raise PresentationInvalid.new(
168
+ "This presentation was issued for an already-existing represented party, not for creation.",
169
+ result: Verification::Result.failure(
170
+ :represented_party_creation_flow_mismatch,
171
+ policy_key: policy.key
172
+ )
173
+ )
174
+ end
175
+
176
+ expected_reference = @represented_party.nil? ? "" : Reference.represented_party(@represented_party)
177
+ expected_type = @represented_party&.class&.name.to_s
178
+
179
+ unless Digest.secure_compare?(manifest.represented_party_reference.to_s, expected_reference) &&
180
+ Digest.secure_compare?(manifest.represented_party_type.to_s, expected_type)
181
+ raise PresentationInvalid.new(
182
+ "This presentation was issued for a different represented party.",
183
+ result: Verification::Result.failure(:represented_party_mismatch, policy_key: policy.key)
184
+ )
185
+ end
186
+
187
+ authority_was_verified = @represented_party.nil? ||
188
+ manifest.authority_at_presentation.to_h["state"] == "verified"
189
+ return if for_replay || (manifest.authority_rule == policy.authority_rule&.to_snapshot &&
190
+ authority_was_verified)
191
+
192
+ raise PresentationInvalid.new(
193
+ "The represented-party authority rule changed after this presentation was issued. Re-render it.",
194
+ result: Verification::Result.failure(
195
+ :represented_party_authority_mismatch,
196
+ policy_key: policy.key
197
+ )
198
+ )
199
+ end
200
+
201
+ def verify_represented_party_creation_binding!(manifest, for_replay:)
202
+ unless @creating_represented_party &&
203
+ @represented_party.respond_to?(:new_record?) && @represented_party.new_record?
204
+ raise PresentationInvalid.new(
205
+ "A represented-party creation presentation must be completed through " \
206
+ "`Clickwrap.create_represented_party!` with the exact new record.",
207
+ result: Verification::Result.failure(
208
+ :represented_party_creation_flow_mismatch,
209
+ policy_key: policy.key
210
+ )
211
+ )
212
+ end
213
+
214
+ expected_flow_id = @represented_party_creation_flow_id.to_s
215
+ expected_reference = "represented_party_creation/#{expected_flow_id}"
216
+ binding_matches = expected_flow_id.present? &&
217
+ Digest.secure_compare?(
218
+ manifest.represented_party_creation_flow_id.to_s,
219
+ expected_flow_id
220
+ ) &&
221
+ Digest.secure_compare?(
222
+ manifest.represented_party_reference.to_s,
223
+ expected_reference
224
+ ) &&
225
+ Digest.secure_compare?(
226
+ manifest.represented_party_type.to_s,
227
+ @represented_party.class.name.to_s
228
+ )
229
+ unless binding_matches
230
+ raise PresentationInvalid.new(
231
+ "This represented-party creation presentation belongs to a different browser flow or record type.",
232
+ result: Verification::Result.failure(
233
+ :represented_party_creation_flow_mismatch,
234
+ policy_key: policy.key
235
+ )
236
+ )
237
+ end
238
+
239
+ authority_snapshot_is_honest =
240
+ manifest.authority_at_presentation.to_h["state"] == "not_yet_verifiable"
241
+ rule_matches = manifest.authority_rule == policy.authority_rule&.to_snapshot &&
242
+ policy.authority_rule&.allows_represented_party_creation?
243
+ return if for_replay || (rule_matches && authority_snapshot_is_honest)
244
+
245
+ raise PresentationInvalid.new(
246
+ "The represented-party creation authority rule changed after this presentation was issued. Re-render it.",
247
+ result: Verification::Result.failure(
248
+ :represented_party_authority_mismatch,
249
+ policy_key: policy.key
250
+ )
251
+ )
252
+ end
253
+
254
+ def verify_registration_binding!(manifest)
255
+ if @prospective_actor
256
+ if @prospective_actor.persisted?
257
+ raise PresentationInvalid,
258
+ "Registration evidence can only be bound while the account is new — this " \
259
+ "prospective actor is already persisted. If you meant to capture for a " \
260
+ "verified person, use capture! with `actor:`. A typed email address on a " \
261
+ "public form is not identity proof: create a separate pending request, " \
262
+ "verify control of the address, and only then capture for the existing actor."
263
+ end
264
+
265
+ unless manifest.registration_flow_id.present? &&
266
+ Digest.secure_compare?(manifest.registration_flow_id.to_s, @registration_flow_id.to_s)
267
+ raise PresentationInvalid.new(
268
+ "This registration presentation belongs to a different browser registration flow.",
269
+ result: Verification::Result.failure(:registration_flow_mismatch, policy_key: policy.key)
270
+ )
271
+ end
272
+
273
+ return if manifest.prospective_actor_type == @prospective_actor.class.name
274
+
275
+ raise PresentationInvalid.new(
276
+ "This registration presentation was issued for a different account type.",
277
+ result: Verification::Result.failure(:registration_actor_type_mismatch, policy_key: policy.key)
278
+ )
279
+ end
280
+
281
+ return if manifest.registration_flow_id.blank?
282
+
283
+ raise PresentationInvalid.new(
284
+ "A registration presentation cannot be used as an ordinary signed-in capture.",
285
+ result: Verification::Result.failure(:registration_flow_mismatch, policy_key: policy.key)
286
+ )
287
+ end
288
+
289
+ def verify_document_digests!(manifest)
290
+ verified = {}
291
+
292
+ manifest.statements.each do |statement|
293
+ Array(statement["documents"]).each do |document|
294
+ version_id = document["version_id"].to_s
295
+ version = verified[version_id] || DocumentVersion.find_by(id: document["version_id"])
296
+
297
+ source_matches = version &&
298
+ Digest.secure_compare?(version.content_digest, document["source_digest"])
299
+ rendered_matches = version && Digest.secure_compare?(
300
+ version.rendered_content_digest.presence || version.content_digest,
301
+ document["rendered_digest"]
302
+ )
303
+ metadata_matches = version &&
304
+ document["path"].present? &&
305
+ (version.rendered_media_type.presence || version.media_type) ==
306
+ document["rendered_media_type"] &&
307
+ version.media_type == document["source_media_type"]
308
+
309
+ if source_matches && rendered_matches && metadata_matches &&
310
+ version.verify_content_digest && version.verify_rendered_content_digest
311
+ verified[version_id] = version
312
+ next
313
+ end
314
+
315
+ raise PresentationInvalid.new(
316
+ "The document #{document["key"]} version #{document["version"]} no longer matches " \
317
+ "what this presentation offered. Re-render the form so the person sees the current " \
318
+ "version before acting on it.",
319
+ result: Verification::Result.failure(
320
+ :document_digest_mismatch,
321
+ policy_key: policy.key,
322
+ details: { "document" => document["key"] }
323
+ )
324
+ )
325
+ end
326
+ end
327
+
328
+ verified
329
+ end
330
+
331
+ def load_revision!(manifest, require_current_policy:)
332
+ revision = PolicyRevision.find_by(
333
+ policy_key: policy.key,
334
+ revision_digest: manifest.revision_digest
335
+ )
336
+
337
+ unless revision
338
+ raise PresentationInvalid.new(
339
+ "The policy revision this presentation was issued under is no longer on file.",
340
+ result: Verification::Result.failure(:stale_policy_revision, policy_key: policy.key)
341
+ )
342
+ end
343
+
344
+ computed = Digest.digest_canonical(revision.compiled_snapshot)
345
+ unless Digest.secure_compare?(computed, revision.revision_digest)
346
+ raise PresentationInvalid.new(
347
+ "The frozen policy revision no longer matches its recorded digest.",
348
+ result: Verification::Result.failure(:integrity_check_failed, policy_key: policy.key)
349
+ )
350
+ end
351
+
352
+ return revision unless require_current_policy
353
+ return revision if revision.matches_loaded_policy?
354
+
355
+ raise PresentationInvalid.new(
356
+ "The policy changed after this presentation was issued. Re-render it so every " \
357
+ "server-side rule and callback is the reviewed current revision.",
358
+ result: Verification::Result.failure(:stale_policy_revision, policy_key: policy.key)
359
+ )
360
+ end
361
+
362
+ def frozen_statement_snapshots(revision)
363
+ Array(revision.compiled_snapshot["statements"]).index_by do |statement|
364
+ statement.fetch("key")
365
+ end
366
+ end
367
+
368
+ def verify_manifest_matches_revision!(manifest, statement_snapshots)
369
+ manifest_keys = manifest.statements.map { |statement| statement["key"] }
370
+ mismatch = manifest_keys != statement_snapshots.keys
371
+
372
+ manifest.statements.each do |offered|
373
+ frozen = statement_snapshots[offered["key"]]
374
+ mismatch ||= frozen.nil? || manifest_structure(offered) != revision_structure(frozen)
375
+ end
376
+
377
+ return unless mismatch
378
+
379
+ raise PresentationInvalid.new(
380
+ "The presentation does not exactly match its frozen policy revision. Re-render it " \
381
+ "instead of accepting ambiguous evidence.",
382
+ result: Verification::Result.failure(:presentation_invalid, policy_key: policy.key)
383
+ )
384
+ end
385
+
386
+ # One answer is about to become several, so the shape that licenses that
387
+ # is checked against the frozen revision rather than trusted because it
388
+ # arrived signed. Every covered statement must exist, must be REQUIRED,
389
+ # and must be a plain checkbox: fanning a ticked box out onto an optional
390
+ # consent would manufacture a grant nobody gave, and onto a choice would
391
+ # invent a decision nobody made.
392
+ def verify_combined_control!(manifest, statement_snapshots)
393
+ covered = manifest.combined_statement_keys
394
+ return if covered.empty?
395
+
396
+ answered_as = manifest.combined_answered_as
397
+ composable = covered.all? do |key|
398
+ snapshot = statement_snapshots[key]
399
+ snapshot && snapshot["required"] && snapshot["choices"].nil?
400
+ end
401
+ return if composable && covered.include?(answered_as)
402
+
403
+ raise PresentationInvalid.new(
404
+ "This presentation offers one control for #{covered.join(", ")}, which the frozen " \
405
+ "policy revision does not allow a single answer to cover. Re-render it instead of " \
406
+ "recording several acts from one box.",
407
+ result: Verification::Result.failure(:presentation_invalid, policy_key: policy.key)
408
+ )
409
+ end
410
+
411
+ def manifest_structure(statement)
412
+ {
413
+ "key" => statement["key"],
414
+ "kind" => statement["kind"],
415
+ "documents" => Array(statement["documents"]).map { |document| document["key"] },
416
+ "choices" => statement["choices"],
417
+ "required" => statement["required"],
418
+ "optional" => statement["optional"],
419
+ "requires_an_explicit_choice" => statement["requires_an_explicit_choice"],
420
+ "requires_current_version" => statement["requires_current_version"] || false,
421
+ "one_time" => statement["one_time"] || false,
422
+ "purpose_key" => statement["purpose"],
423
+ "withdrawal_path" => statement["withdrawal_path"],
424
+ "valid_for_seconds" => statement["valid_for_seconds"],
425
+ "requires" => Array(statement["requires"]),
426
+ "subject_fingerprint_version" => statement["subject_fingerprint_version"],
427
+ "protected_outcome_version" => statement["protected_outcome_version"]
428
+ }
429
+ end
430
+
431
+ def revision_structure(statement)
432
+ keys = %w[
433
+ key kind documents choices required optional requires_an_explicit_choice
434
+ requires_current_version one_time purpose_key withdrawal_path valid_for_seconds
435
+ requires subject_fingerprint_version protected_outcome_version
436
+ ]
437
+
438
+ keys.to_h { |key| [key, statement[key]] }
439
+ end
440
+
441
+ def collect_answers(manifest)
442
+ return @explicit_answers.to_h.transform_keys(&:to_s) unless @explicit_answers.nil?
443
+
444
+ answers = manifest.statements.to_h do |statement|
445
+ [statement["key"], submission.answer_for(statement["key"])]
446
+ end
447
+
448
+ fan_out_combined_answer(manifest, answers)
449
+ end
450
+
451
+ # One control, many statements. The offer the SERVER signed says which
452
+ # statement keys that single control answered, so the one submitted value
453
+ # becomes every covered statement's answer here — including when it is
454
+ # absent or unticked, which is how one empty box refuses every act the
455
+ # sentence named.
456
+ #
457
+ # This is a server-side fan-out and never a merge: a browser that also
458
+ # sends an answer for a covered statement (an ejected view still rendering
459
+ # the itemized shape, or somebody hand-posting a half-consent) is
460
+ # overwritten rather than believed. The development linter reports a page
461
+ # that rendered those extra controls; nothing about it changes what gets
462
+ # recorded.
463
+ def fan_out_combined_answer(manifest, answers)
464
+ covered = manifest.combined_statement_keys
465
+ return answers if covered.empty?
466
+
467
+ given = submission.answer_for(manifest.combined_answered_as)
468
+ covered.each { |key| answers[key] = given }
469
+ answers
470
+ end
471
+
472
+ def validate_answers!(manifest, answers, statement_snapshots)
473
+ submitted_keys = submission ? submission.answers.keys : answers.keys
474
+ unknown = submitted_keys - manifest.statements.map { |statement| statement["key"] }
475
+
476
+ unless unknown.empty?
477
+ raise SubmissionInvalid,
478
+ "The submission answers #{unknown.join(", ")}, which this presentation never " \
479
+ "offered. Answers are only accepted for the statements the server declared."
480
+ end
481
+
482
+ statement_snapshots.each_value do |statement|
483
+ validate_answer!(statement, answers[statement.fetch("key")])
484
+ end
485
+ end
486
+
487
+ def validate_answer!(statement, value)
488
+ if statement["choices"]
489
+ validate_choice!(statement, value)
490
+ elsif statement["required"] && !Submission.affirmative?(value)
491
+ raise AnswerInvalid.new(
492
+ "#{statement["key"]} is required and was not answered.",
493
+ statement_key: statement["key"],
494
+ reason: :missing_answer
495
+ )
496
+ end
497
+ end
498
+
499
+ def validate_choice!(statement, value)
500
+ if value.blank?
501
+ return unless statement["requires_an_explicit_choice"] || statement["required"]
502
+
503
+ raise AnswerInvalid.new(
504
+ "#{statement["key"]} needs an explicit choice; none was submitted.",
505
+ statement_key: statement["key"],
506
+ reason: :missing_answer
507
+ )
508
+ end
509
+
510
+ return if statement["choices"].key?(value.to_s)
511
+
512
+ raise AnswerInvalid.new(
513
+ "#{value.inspect} is not one of the choices offered for #{statement["key"]} " \
514
+ "(#{statement["choices"].keys.join(", ")}).",
515
+ statement_key: statement["key"],
516
+ reason: :missing_answer
517
+ )
518
+ end
519
+ end
520
+ end
521
+ end