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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- 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
|