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,606 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Clickwrap
6
+ # The answer to "show me exactly what the application recorded."
7
+ #
8
+ # One canonical JSON body per event, plus a human-readable projection of the
9
+ # same facts. The canonical form is what gets digested and independently
10
+ # verified, so it is built from the stored evidence alone: no Ruby object
11
+ # serialization, no current policy source, no database column order, no
12
+ # locale-dependent number formatting. A verifier written years from now in
13
+ # another language must be able to reproduce these bytes from the data.
14
+ #
15
+ # Raw IP address, browser user-agent, and IP-geolocation values are NOT in the
16
+ # canonical body. They live in a separately encrypted annex with its own
17
+ # authorization, retention, hold, and disposition state, and the body carries
18
+ # only a keyed digest binding the two. That boundary is what lets the core
19
+ # event stay immutable and verifiable when a permitted retention process later
20
+ # removes the annex — deletion changes what a receipt can show, not what it
21
+ # says happened.
22
+ class Receipt
23
+ SCHEMA = "clickwrap.receipt.v1"
24
+
25
+ # The Clickwrap profile for timestamps: UTC, exactly six fractional digits,
26
+ # `Z` suffix. Fixed width so two verifiers never disagree about whether a
27
+ # trailing zero was significant.
28
+ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%6NZ"
29
+
30
+ attr_reader :event
31
+
32
+ def initialize(event)
33
+ @event = event
34
+ end
35
+
36
+ class << self
37
+ def find(event_id)
38
+ # The annex is eager-loaded only where it can exist. An installation
39
+ # that records no request evidence never created that table, and
40
+ # `includes` would read its schema before deciding there is nothing in
41
+ # it. The receipt still reports every category by state — the states
42
+ # just all come out `not_configured`, which is the true answer.
43
+ associations = %i[statements documents policy_revision]
44
+ associations << :request_evidence if SchemaRequirements.available?(:request_evidence)
45
+
46
+ event = Event.includes(*associations).find_by(id: event_id)
47
+
48
+ raise ReceiptInvalid, "No Clickwrap event with id #{event_id.inspect}" unless event
49
+
50
+ new(event)
51
+ end
52
+
53
+ def format_time(time)
54
+ return nil if time.nil?
55
+
56
+ time.utc.strftime(TIME_FORMAT)
57
+ end
58
+
59
+ # An export names each sensitive field it includes. There is deliberately
60
+ # no `include_sensitive_context: true`: one flag that turns on three
61
+ # different categories of personal data is exactly the kind of option that
62
+ # makes an operator's intent unreviewable.
63
+ def export(receipt, requested_by: nil, because: nil,
64
+ include_ip_address: false, include_browser_user_agent: false,
65
+ include_ip_geolocation: false, access_channel: "export")
66
+ receipt = find(receipt) if receipt.is_a?(String)
67
+ requested = { ip_address: include_ip_address,
68
+ browser_user_agent: include_browser_user_agent,
69
+ ip_geolocation: include_ip_geolocation }
70
+
71
+ authorize_export!(receipt, requested, requested_by, because)
72
+ # A redacted receipt contains no annex value and therefore performs no
73
+ # privileged read. It needs neither an access row nor a transaction of
74
+ # its own; making ordinary exports depend on commit context would make
75
+ # harmless rendering fail inside otherwise unrelated host work.
76
+ return receipt.to_h unless requested.value?(true)
77
+
78
+ refuse_export_inside_an_outer_transaction!
79
+
80
+ exported = nil
81
+ Event.transaction(requires_new: true) do
82
+ event = Event.lock.find(receipt.event.id)
83
+ current_receipt = new(event)
84
+ included = requested.transform_keys(&:to_s)
85
+ requested_by_reference = Reference.actor(requested_by)
86
+
87
+ ReceiptAccess.record!(
88
+ event: event,
89
+ requested_by: requested_by,
90
+ because: because,
91
+ included_fields: included,
92
+ access_channel: access_channel
93
+ )
94
+
95
+ Lifecycle.append_lifecycle_event!(
96
+ event: event,
97
+ event_type: "receipt_access",
98
+ reason: because.presence || "Exported a redacted receipt",
99
+ actor: requested_by,
100
+ extra: {
101
+ protected_outcome: {
102
+ "receipt_access" => {
103
+ "requested_by_reference" => requested_by_reference,
104
+ "included_fields" => included,
105
+ "access_channel" => access_channel.to_s
106
+ }.compact
107
+ }
108
+ }
109
+ )
110
+
111
+ exported = current_receipt.send(
112
+ :to_h_with_revealed_request_evidence,
113
+ requested.select { |_, wanted| wanted }.keys
114
+ )
115
+ end
116
+
117
+ exported
118
+ end
119
+
120
+ # Verifies a receipt with no host application, no database, and no policy
121
+ # source — the standalone path the CLI uses.
122
+ def verify(canonical_json, documents: {})
123
+ ReceiptVerifier.verify(canonical_json, documents: documents)
124
+ end
125
+
126
+ private
127
+
128
+ def authorize_export!(receipt, requested, requested_by, because)
129
+ return unless requested.any? { |_, wanted| wanted }
130
+
131
+ if because.to_s.strip.empty?
132
+ raise AccessNotAuthorized,
133
+ "Reading unredacted request evidence needs a `because:` naming the specific " \
134
+ "reason — an investigation, a dispute, a request. It is recorded with the access."
135
+ end
136
+
137
+ permitted = Clickwrap.config.authorize_unredacted_request_evidence_access_with
138
+ .call(requested_by, receipt, because)
139
+
140
+ return if permitted
141
+
142
+ raise AccessNotAuthorized,
143
+ "The host's authorize_unredacted_request_evidence_access_with callback declined " \
144
+ "this request for #{requested.select { |_, w| w }.keys.join(", ")}."
145
+ end
146
+
147
+ def refuse_export_inside_an_outer_transaction!
148
+ return unless ::ActiveRecord::Base.connection.transaction_open?
149
+
150
+ raise AccessNotAuthorized,
151
+ "Receipt export cannot run inside an outer database transaction. Clickwrap records " \
152
+ "the access event before returning any export; call it after the surrounding " \
153
+ "transaction commits so the audit record cannot later roll back."
154
+ end
155
+ end
156
+
157
+ # --- Identity -------------------------------------------------------------
158
+
159
+ def event_id = event.id
160
+ def policy_key = event.policy_key
161
+ def recorded_at_by_server = event.recorded_at_by_server
162
+ def actor_reference = event.actor_reference
163
+ def actor = event.actor
164
+ def subject = event.subject
165
+ def committed? = true
166
+
167
+ def statements = event.statements
168
+ def documents = event.documents
169
+ def request_evidence = event.request_evidence
170
+
171
+ def held? = event.on_legal_hold?
172
+
173
+ # --- Serialization --------------------------------------------------------
174
+
175
+ # The canonical, digestible body.
176
+ #
177
+ # `integrity.receipt_digest` covers this exact body with only the
178
+ # self-referential `integrity.receipt_digest` field removed — the digest
179
+ # cannot cover itself, and the exclusion has to be one a verifier in another
180
+ # language can reproduce without guessing. It is a
181
+ # different value from `integrity.event_digest`, which the application
182
+ # computed over the event's own canonical body when the event was written.
183
+ # Both are reported, because they answer different questions: one says this
184
+ # file has not been edited, the other says the row it describes has not been.
185
+ #
186
+ # An export that reveals request evidence is a different document from a
187
+ # redacted one, so it carries a different receipt digest. That is correct:
188
+ # each file verifies as the file it actually is.
189
+ def to_h
190
+ build_receipt_body(revealed_request_evidence: [])
191
+ end
192
+
193
+ def to_h_with_revealed_request_evidence(categories)
194
+ build_receipt_body(revealed_request_evidence: Array(categories).map(&:to_sym))
195
+ end
196
+ private :to_h_with_revealed_request_evidence
197
+
198
+ def build_receipt_body(revealed_request_evidence:)
199
+ body = build_body(reveal: revealed_request_evidence)
200
+ algorithm = event.digest_algorithm.presence || "sha256"
201
+ covered = body.deep_dup
202
+
203
+ body["integrity"]["receipt_digest"] = Digest.digest_canonical(covered, algorithm: algorithm)
204
+ body
205
+ end
206
+ private :build_receipt_body
207
+
208
+ def to_canonical_json = CanonicalJson.generate(to_h)
209
+
210
+ def to_json(*) = to_canonical_json
211
+
212
+ def as_json(*) = to_h
213
+
214
+ # The human-readable projection. Same facts, rendered — never a different
215
+ # set of facts, and never a stronger claim than the canonical body makes.
216
+ def to_html(view_context: nil) = ReceiptHtml.new(self, view_context: view_context).render
217
+
218
+ def build_body(reveal:)
219
+ {
220
+ "schema" => SCHEMA,
221
+ "event_id" => event.id,
222
+ "event_type" => event.event_type,
223
+ "event" => event.canonical_body,
224
+ "policy" => policy_fragment,
225
+ "actor" => actor_fragment,
226
+ "acts" => statements.map(&:canonical_fragment),
227
+ "documents" => documents.map(&:canonical_fragment),
228
+ "presentation" => presentation_fragment,
229
+ "outcome" => event.protected_outcome.presence,
230
+ "lifecycle" => lifecycle_fragment,
231
+ "provider" => provider_fragment,
232
+ "request_evidence" => request_evidence_fragment(reveal),
233
+ "retention" => retention_fragment,
234
+ "integrity" => integrity_fragment,
235
+ "recorded_at_by_server" => self.class.format_time(event.recorded_at_by_server),
236
+ "occurred_at" => self.class.format_time(event.occurred_at),
237
+ "system" => system_fragment,
238
+ "verifier_instructions" => verifier_instructions
239
+ }.compact
240
+ end
241
+ private :build_body
242
+
243
+ # PDF is optional rendering of the receipt, never the source of truth. The
244
+ # gem ships no PDF dependency; a host configures a renderer if it wants one.
245
+ def to_pdf(*)
246
+ raise ConfigurationError,
247
+ "Clickwrap does not render PDFs itself — a PDF library is not a dependency of an " \
248
+ "evidence gem, and a PDF is a rendering of the receipt rather than the record. " \
249
+ "Render `to_html` with your own PDF pipeline if you need one."
250
+ end
251
+
252
+ # --- Verification ---------------------------------------------------------
253
+
254
+ def verify
255
+ Verification.verify(event.id)
256
+ end
257
+
258
+ def digest_verified? = event.digest_verified?
259
+
260
+ # --- Legal holds ----------------------------------------------------------
261
+
262
+ def place_on_legal_hold!(because:, placed_by:, review_at:)
263
+ SchemaRequirements.require!(:retention_ops)
264
+
265
+ hold = nil
266
+
267
+ ::ActiveRecord::Base.transaction do
268
+ locked_event = Event.lock.find(event.id)
269
+ hold = LegalHold.create!(
270
+ hold_scope: "event",
271
+ event_id: event.id,
272
+ reason: because,
273
+ placed_by_reference: reference_for(placed_by),
274
+ placed_at: Clickwrap.now,
275
+ review_at: review_at,
276
+ created_at: Clickwrap.now
277
+ )
278
+
279
+ locked_event.set_legal_hold!(true)
280
+ Lifecycle.append_lifecycle_event!(event: locked_event, event_type: "legal_hold_placed",
281
+ reason: because, actor: placed_by)
282
+ end
283
+
284
+ hold
285
+ end
286
+
287
+ def release_legal_hold!(because:, released_by:)
288
+ ::ActiveRecord::Base.transaction do
289
+ locked_event = Event.lock.find(event.id)
290
+ holds = LegalHold.lock.for_event(event.id).in_effect.to_a
291
+ return nil if holds.empty?
292
+
293
+ holds.each do |hold|
294
+ hold.release!(because: because, released_by: released_by)
295
+ end
296
+
297
+ locked_event.set_legal_hold!(LegalHold.for_event(event.id).in_effect.exists?)
298
+ Lifecycle.append_lifecycle_event!(event: locked_event, event_type: "legal_hold_released",
299
+ reason: because, actor: released_by)
300
+ end
301
+ end
302
+
303
+ def legal_holds = LegalHold.for_event(event.id)
304
+
305
+ def inspect = "#<Clickwrap::Receipt #{event_id} #{policy_key}>"
306
+
307
+ def to_s = event_id
308
+
309
+ private
310
+
311
+ def policy_fragment
312
+ {
313
+ "key" => event.policy_key,
314
+ "revision" => event.policy_revision&.revision_digest,
315
+ "retention_class" => event.retention_class_key
316
+ }.compact
317
+ end
318
+
319
+ def actor_fragment
320
+ {
321
+ "reference" => event.actor_reference,
322
+ "attribution" => {
323
+ "method" => event.attribution_method,
324
+ "authenticated" => event.attribution_method == "authenticated_session"
325
+ },
326
+ "authentication_method" => event.authentication_method,
327
+ "snapshot" => event.actor_snapshot.presence,
328
+ "acting_for" => acting_for_fragment,
329
+ "tenant" => event.tenant_key,
330
+ "subject" => subject_fragment
331
+ }.compact
332
+ end
333
+
334
+ def acting_for_fragment
335
+ return nil if event.represented_party_reference.blank?
336
+
337
+ {
338
+ "type" => event.represented_party_type,
339
+ "reference" => event.represented_party_reference,
340
+ "authority_source" => event.authority_source,
341
+ "authority_role" => event.authority_role,
342
+ "authority_verified_at" => self.class.format_time(event.authority_verified_at),
343
+ "authority_details" => event.authority_details.presence
344
+ }.compact
345
+ end
346
+
347
+ def subject_fragment
348
+ return nil if event.subject_key.blank?
349
+
350
+ { "reference" => event.subject_key, "fingerprint" => event.subject_fingerprint }.compact
351
+ end
352
+
353
+ def presentation_fragment
354
+ return nil if event.presentation_manifest_digest.blank?
355
+
356
+ manifest = event.presentation_manifest || {}
357
+
358
+ {
359
+ "manifest_digest" => event.presentation_manifest_digest,
360
+ "submit_button_text" => manifest["submit_button_text"],
361
+ # Present only when the offer was ONE control carrying one composed
362
+ # sentence. The acts below say what was recorded; this says what the
363
+ # person actually read, which is the thing a substitution argument is
364
+ # about, and which statements that single answer covered.
365
+ "combined_sentence" => manifest.dig("combined_control", "sentence"),
366
+ "combined_statements" => manifest.dig("combined_control", "covers"),
367
+ "locale" => manifest["locale"],
368
+ "capture_channel" => event.capture_channel,
369
+ "offered_at" => manifest["issued_at"],
370
+ # Said plainly, in the receipt itself, so nobody has to infer it: this
371
+ # is what the server generated and accepted, not what a person read.
372
+ "proves" => "The server generated this presentation manifest and accepted a submission " \
373
+ "bound to it. It does not establish that the person read or understood the " \
374
+ "documents, saw particular pixels, or received a legally sufficient interface."
375
+ }.compact
376
+ end
377
+
378
+ def lifecycle_fragment
379
+ successors = Event.where(root_event_id: event.id).or(Event.where(predecessor_event_id: event.id))
380
+ .chronological
381
+
382
+ fragment = {
383
+ "root_event_id" => event.root_event_id,
384
+ "predecessor_event_id" => event.predecessor_event_id,
385
+ "successors" => successors.map do |successor|
386
+ {
387
+ "event_id" => successor.id,
388
+ "event_type" => successor.event_type,
389
+ "recorded_at_by_server" => self.class.format_time(successor.recorded_at_by_server),
390
+ "reason" => successor.reason,
391
+ # A lifecycle summary without the successor's independently
392
+ # digestible body could be rewritten and covered only by the
393
+ # self-contained receipt digest. Embedding both lets the standalone
394
+ # verifier re-derive every event that changed this receipt's state.
395
+ "event" => successor.canonical_body,
396
+ "event_digest" => successor.event_digest
397
+ }.compact
398
+ end
399
+ }.compact
400
+
401
+ fragment["successors"].empty? && fragment.length == 1 ? nil : fragment
402
+ end
403
+
404
+ def provider_fragment
405
+ return nil if event.provider_name.blank?
406
+
407
+ {
408
+ "name" => event.provider_name,
409
+ "event_id" => event.provider_event_id,
410
+ "verification" => event.provider_verification.presence,
411
+ # An imported provider event is not a click this application captured,
412
+ # and the receipt says so rather than letting the two look alike.
413
+ "note" => "Recorded from an external provider's receipt. Clickwrap did not present this " \
414
+ "content or observe this action."
415
+ }.compact
416
+ end
417
+
418
+ # Five distinct states, kept distinct. "Blank" is never allowed to blur "we
419
+ # chose not to collect this" into "collection failed" into "we deleted it
420
+ # under a retention rule" — those tell a reader completely different things.
421
+ def request_evidence_fragment(reveal)
422
+ annex = request_evidence
423
+
424
+ RequestEvidence::CATEGORIES.to_h do |category|
425
+ [category.to_s, category_fragment(annex, category, reveal)]
426
+ end
427
+ end
428
+
429
+ def category_fragment(annex, category, reveal)
430
+ return { "state" => "not_configured" } if annex.nil?
431
+
432
+ authorized = reveal.include?(category)
433
+ state = annex.state_for(category, authorized_to_read: authorized, held: held?)
434
+
435
+ fragment = { "state" => state }
436
+ fragment["unavailable_reason"] = annex.unavailable_reason_for(category) if state == "unavailable"
437
+ fragment["deleted_at"] = self.class.format_time(annex.public_send(:"#{category}_deleted_at")) if
438
+ state == "deleted_after_retention"
439
+
440
+ fragment.merge!(revealed_values(annex, category)) if authorized && state == "recorded"
441
+ fragment
442
+ end
443
+
444
+ def revealed_values(annex, category)
445
+ case category
446
+ when :ip_address
447
+ {
448
+ "value" => annex.ip_address,
449
+ "reader" => annex.ip_address_reader_name,
450
+ "trusted_proxy_configuration_digest" => annex.trusted_proxy_configuration_digest,
451
+ "recorded_at" => self.class.format_time(annex.ip_address_recorded_at),
452
+ # Stated in the receipt because the alternative is a reader assuming
453
+ # otherwise: an address is a network observation about a request.
454
+ "means" => "IP address observed by the configured reader for this request. Not identity."
455
+ }.compact
456
+ when :browser_user_agent
457
+ {
458
+ "value" => annex.browser_user_agent,
459
+ "was_client_supplied" => annex.browser_user_agent_was_client_supplied?,
460
+ "recorded_at" => self.class.format_time(annex.browser_user_agent_recorded_at),
461
+ "means" => "The User-Agent header the client sent. Client-supplied, and not a device " \
462
+ "identity or proof of what was rendered."
463
+ }.compact
464
+ when :ip_geolocation
465
+ geolocation_values(annex)
466
+ end
467
+ end
468
+
469
+ def geolocation_values(annex)
470
+ values = annex.authorized_ip_geolocation_fields.to_h do |field|
471
+ [field, geolocation_field_value(annex, field)]
472
+ end.compact
473
+
474
+ values.merge(
475
+ "provider_name" => annex.ip_geolocation_provider_name,
476
+ "provider_source" => annex.ip_geolocation_provider_source,
477
+ "database_version" => annex.ip_geolocation_database_version,
478
+ "database_sha256" => annex.ip_geolocation_database_sha256,
479
+ "accuracy_radius_confidence_percentage" => annex.ip_geolocation_accuracy_radius_confidence_percentage,
480
+ "was_estimated" => annex.ip_geolocation_was_estimated?,
481
+ "source_was_verified_by_host" => annex.ip_geolocation_source_was_verified_by_host?,
482
+ "resolved_at" => self.class.format_time(annex.ip_geolocation_resolved_at),
483
+ "means" => "One provider's estimate for the observed IP address at the time shown. " \
484
+ "Not GPS, not a street address, and not proof that the person was there."
485
+ ).compact
486
+ end
487
+
488
+ def geolocation_field_value(annex, field)
489
+ case field
490
+ when "country" then annex.ip_geolocation_country_code
491
+ when "region" then annex.ip_geolocation_region_code || annex.ip_geolocation_region_name
492
+ when "city" then annex.ip_geolocation_city_name
493
+ when "postal_code" then annex.ip_geolocation_postal_code
494
+ when "latitude_and_longitude"
495
+ latitude = annex.ip_geolocation_latitude
496
+ longitude = annex.ip_geolocation_longitude
497
+ # Both or neither: half a coordinate is not a result, and a lone
498
+ # latitude in an export invites someone to pair it with a guess.
499
+ latitude && longitude ? { "latitude" => latitude.to_s, "longitude" => longitude.to_s } : nil
500
+ when "timezone" then annex.ip_geolocation_timezone
501
+ when "continent" then annex.ip_geolocation_continent_code
502
+ when "metro_code" then annex.ip_geolocation_metro_code
503
+ when "accuracy_radius_in_kilometers" then annex.ip_geolocation_accuracy_radius_in_kilometers
504
+ end
505
+ end
506
+
507
+ def retention_fragment
508
+ {
509
+ "class" => event.retention_class_key,
510
+ "core_event_retained_until" => self.class.format_time(event.retain_core_event_until),
511
+ "retention_rule" => event.retention_rule_name,
512
+ "core_event_disposed_at" => self.class.format_time(event.core_event_disposed_at),
513
+ "on_legal_hold" => event.on_legal_hold?
514
+ }.compact
515
+ end
516
+
517
+ # Each tier states exactly what it detects. The baseline claim is
518
+ # deliberately modest, because a hash computed and stored by the same system
519
+ # that stored the record cannot say more than this.
520
+ def integrity_fragment
521
+ {
522
+ "digest_algorithm" => event.digest_algorithm,
523
+ "event_digest" => event.event_digest,
524
+ "previous_event_digest" => event.previous_event_digest,
525
+ "chain_scope" => event.chain_scope,
526
+ "chain_sequence" => event.chain_sequence,
527
+ "request_evidence_category_binding_digests" =>
528
+ event.request_evidence_category_binding_digests.presence,
529
+ "request_evidence_digest_algorithm" => event.request_evidence_digest_algorithm,
530
+ "request_evidence_key_id" => event.request_evidence_key_id,
531
+ "request_evidence_binding_status" => event.request_evidence_binding_status.to_s,
532
+ "attestations" => event.integrity_attestations.map(&:canonical_fragment).presence,
533
+ "tier" => integrity_tier,
534
+ "detects" => integrity_claim
535
+ }.compact
536
+ end
537
+
538
+ def integrity_tier
539
+ verified = event.integrity_attestations.select { |attestation| attestation.verified_for?(event) }
540
+ return "third_party_timestamp" if verified.any? do |attestation|
541
+ attestation.kind == "third_party_timestamp" &&
542
+ attestation.adapter_capabilities.to_h["independently_verifiable"] == true
543
+ end
544
+ return "external_event_anchoring" if verified.any? do |attestation|
545
+ attestation.kind == "event_anchor" &&
546
+ attestation.adapter_capabilities.to_h["publishes_outside_primary_database"] == true
547
+ end
548
+ return "chained_history" if event.chain_scope.present?
549
+
550
+ "baseline"
551
+ end
552
+
553
+ def integrity_claim
554
+ case integrity_tier
555
+ when "third_party_timestamp"
556
+ "A configured timestamp provider returned a token over this exact event digest, and the " \
557
+ "configured adapter recorded a successful verification. The provider result, status, " \
558
+ "and capabilities are preserved so a reader can evaluate that provider's own claim."
559
+ when "external_event_anchoring"
560
+ "A configured adapter reported publishing this exact event chain position outside the " \
561
+ "primary database, and its verifier accepted the stored publication result. This " \
562
+ "improves detection only while that external publication remains available and trustworthy."
563
+ when "chained_history"
564
+ "Event digests are chained, which makes a rewrite of history detectable for as long as " \
565
+ "the chain head remains trustworthy."
566
+ else
567
+ "The recorded digest detects accidental or ordinary modification of the bytes it covers. " \
568
+ "It does not establish who produced them, when, or that a party controlling both the " \
569
+ "application and the database could not have written both the record and the digest."
570
+ end
571
+ end
572
+
573
+ def system_fragment
574
+ {
575
+ "gem_version" => event.gem_version,
576
+ "application_version" => event.application_version,
577
+ "template_version" => event.template_version,
578
+ "canonical_schema_version" => event.canonical_schema_version,
579
+ "verifier_version" => Clickwrap::VERIFIER_VERSION
580
+ }.compact
581
+ end
582
+
583
+ def verifier_instructions
584
+ receipt_check =
585
+ "Remove only integrity.receipt_digest, canonicalize the remaining object with RFC 8785 " \
586
+ "(JSON Canonicalization Scheme), and compare it with integrity.receipt_digest."
587
+
588
+ event_check = if event.disposed?
589
+ "The original event payload was disposed of, so its event digest cannot be " \
590
+ "re-derived; verify the retained tombstone and the digest-bound disposition " \
591
+ "successor instead."
592
+ else
593
+ "Canonicalize event and compare it with integrity.event_digest."
594
+ end
595
+
596
+ "#{receipt_check} #{event_check} Verify each documents[].source_digest and " \
597
+ "documents[].rendered_digest against the corresponding source and rendered files in the " \
598
+ "bundle. Run `clickwrap verify receipt.json --documents ./dir` to perform these checks " \
599
+ "without the host application."
600
+ end
601
+
602
+ def reference_for(actor)
603
+ Reference.actor(actor)
604
+ end
605
+ end
606
+ end