clickwrap 0.0.0 → 0.1.1

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 +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -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 +154 -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 +187 -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 +729 -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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Projects retained event payloads onto the current-state table.
5
+ #
6
+ # Everything here is derived from retained event payloads. `rebuild_for!`
7
+ # replays them while they still contain the affected statement identity. A
8
+ # reviewed core disposition deliberately removes that identity, so rebuilding
9
+ # through a disposed root would invent or silently lose state. The method
10
+ # refuses first and leaves the existing projection untouched in that case.
11
+ module CurrentState
12
+ # `imported_legacy` projects like a capture: the source system says the act
13
+ # happened, and a migrated application must keep answering "did this person
14
+ # agree?" the way it answered the day before the migration — otherwise
15
+ # every import ends in a mass forced re-acceptance, which is exactly the
16
+ # history-rewriting an import exists to avoid. What stays different is the
17
+ # evidence, not the answer: the event keeps `imported_provider`
18
+ # attribution, its receipt names every unknown, and
19
+ # `require_current_version` still sends people back when the documents
20
+ # move on. (An exemption, by contrast, records that NO human acted — it
21
+ # projects under its own state and never satisfies a human-action
22
+ # predicate.)
23
+ INITIAL_EVENT_TYPES = (Vocabulary::HUMAN_ACTION_EVENT_TYPES + %w[exemption imported_legacy]).freeze
24
+ TRANSITION_STATE_BY_EVENT_TYPE = {
25
+ "withdrawal" => "withdrawn",
26
+ "supersession" => "superseded",
27
+ "expiry" => "expired",
28
+ "consumption" => "consumed",
29
+ "revocation" => "revoked"
30
+ }.freeze
31
+
32
+ class << self
33
+ # Applies one event to the projection. Runs inside the capture's
34
+ # transaction, so a failure here rolls the capture back rather than
35
+ # leaving evidence whose current state nobody can query.
36
+ def apply!(event)
37
+ return event unless INITIAL_EVENT_TYPES.include?(event.event_type)
38
+ # An import quarantined with `counts_as_current: false` must stay
39
+ # quarantined through every path — including a projection REBUILD.
40
+ # Without this guard, `rebuild_for!` would launder consent the host
41
+ # explicitly declined to honour into an active grant.
42
+ return event unless projects_into_current_state?(event)
43
+
44
+ StatementIdentityLock.acquire_for_actor!(event.actor_reference)
45
+ identities = event.statements.map { |statement| identity_for(event, statement) }
46
+ identities.sort_by { |identity| identity.fetch(:identity_digest) }.each do |identity|
47
+ StatementIdentityLock.acquire!(identity.fetch(:identity_digest))
48
+ end
49
+
50
+ event.statements.each { |statement| apply_statement!(event, statement) }
51
+ end
52
+
53
+ def apply_statement!(event, statement)
54
+ identity = identity_for(event, statement)
55
+
56
+ loop do
57
+ return StatementState.transaction(requires_new: true) do
58
+ state = StatementState.find_or_initialize_by(identity)
59
+ return state if candidate_is_not_newer?(state, event, statement)
60
+
61
+ # A previous grant for the same identity is superseded rather
62
+ # than overwritten. The projection moves on; the event that
63
+ # recorded the earlier act stays exactly where it was.
64
+ state.assign_attributes(
65
+ kind: statement.kind,
66
+ purpose_key: statement.purpose_key,
67
+ actor_type: event.actor_type,
68
+ actor_id: event.actor_id,
69
+ subject_type: event.subject_type,
70
+ subject_id: event.subject_id,
71
+ subject_fingerprint: statement.subject_fingerprint,
72
+ represented_party_reference: event.represented_party_reference.to_s,
73
+ state: state_for(event, statement),
74
+ current_action: statement.action,
75
+ current_event_id: event.id,
76
+ root_event_id: event.root_event_id || event.id,
77
+ policy_revision_id: event.policy_revision_id,
78
+ effective_at: effective_at_for(event, statement),
79
+ expires_at: statement.expires_at,
80
+ one_time: statement.one_time,
81
+ document_version_ids: document_version_ids_for(event, statement)
82
+ )
83
+
84
+ apply_lifecycle_timestamps(state, event, statement)
85
+ state.save!
86
+ state
87
+ end
88
+ rescue ::ActiveRecord::RecordNotUnique
89
+ # PostgreSQL marks a transaction failed after a uniqueness error.
90
+ # The requires_new savepoint above absorbs that state before retry.
91
+ end
92
+ end
93
+
94
+ # Marks a statement's projection with a lifecycle outcome, without
95
+ # touching the event that produced it.
96
+ def transition!(state, to:, event:, at: nil)
97
+ at ||= Clickwrap.now
98
+
99
+ StatementIdentityLock.acquire_for_actor!(state.actor_reference)
100
+ StatementIdentityLock.acquire!(state.identity_digest)
101
+ state = StatementState.lock.find(state.id)
102
+ return state if event_is_not_newer?(state, event, effective_at: at)
103
+
104
+ attributes = {
105
+ state: to.to_s,
106
+ current_event_id: event.id,
107
+ current_action: event_action_for(to),
108
+ effective_at: at
109
+ }
110
+
111
+ case to.to_s
112
+ when "withdrawn" then attributes[:withdrawn_at] = at
113
+ when "superseded" then attributes[:superseded_at] = at
114
+ when "consumed" then attributes[:consumed_at] = at
115
+ when "revoked" then attributes[:revoked_at] = at
116
+ when "corrected" then attributes[:corrected_at] = at
117
+ end
118
+
119
+ state.update!(attributes)
120
+ end
121
+
122
+ # Rebuilds the projection for one actor from retained event payloads.
123
+ # Refuses before deleting anything when an existing state depends on a
124
+ # disposed root whose statement identity can no longer be reconstructed.
125
+ def rebuild_for!(actor_reference:)
126
+ StatementState.transaction do
127
+ StatementIdentityLock.acquire_for_actor!(actor_reference)
128
+ existing_states = StatementState.for_actor(actor_reference).lock.to_a
129
+ ensure_rebuildable!(existing_states, actor_reference)
130
+ StatementState.for_actor(actor_reference).delete_all
131
+
132
+ Event.for_actor(actor_reference)
133
+ .chronological
134
+ .includes(:statements)
135
+ .to_a
136
+ .each { |event| replay!(event) }
137
+ end
138
+ end
139
+
140
+ # Whether this event is allowed to shape the projection. Captures and
141
+ # every other initial type always do; an `imported_legacy` event carries
142
+ # its own answer in the structured provenance it was written with.
143
+ # Imports that predate the flag project (they were written with exactly
144
+ # that intent); only an explicit `counts_as_current: false` quarantines.
145
+ def projects_into_current_state?(event)
146
+ return true unless event.event_type == "imported_legacy"
147
+
148
+ event.provider_verification.to_h["counts_as_current"] != false
149
+ end
150
+
151
+ private
152
+
153
+ def identity_for(event, statement)
154
+ StatementState.identity_for(
155
+ policy_key: event.policy_key,
156
+ statement_key: statement.statement_key,
157
+ actor_reference: event.actor_reference,
158
+ tenant_key: event.tenant_key,
159
+ subject_key: event.subject_key,
160
+ represented_party_reference: event.represented_party_reference
161
+ )
162
+ end
163
+
164
+ def effective_at_for(event, statement)
165
+ statement.valid_from || event.occurred_at || event.recorded_at_by_server
166
+ end
167
+
168
+ # Imports are commonly appended years after the act they describe. They
169
+ # remain in history, but an older effective act must never resurrect a
170
+ # grant that was withdrawn or superseded later. Server-recorded order and
171
+ # durable sequence are deterministic tie-breakers only when effective
172
+ # times are equal; they do not rewrite when the source says the act
173
+ # happened.
174
+ def candidate_is_not_newer?(state, event, statement)
175
+ return false unless state.persisted?
176
+
177
+ event_is_not_newer?(state, event, effective_at: effective_at_for(event, statement))
178
+ end
179
+
180
+ def event_is_not_newer?(state, event, effective_at:)
181
+ current_event = state.current_event
182
+ return false unless current_event
183
+ return true if current_event.id.to_s == event.id.to_s
184
+
185
+ candidate = [effective_at, event.recorded_at_by_server, event.recording_sequence.to_i, event.id.to_s]
186
+ current = [state.effective_at, current_event.recorded_at_by_server,
187
+ current_event.recording_sequence.to_i, current_event.id.to_s]
188
+ (candidate <=> current) <= 0
189
+ end
190
+
191
+ def ensure_rebuildable!(states, actor_reference)
192
+ root_ids = states.map(&:root_event_id).compact.uniq
193
+ disposed_root_id = Event.where(id: root_ids).where.not(core_event_disposed_at: nil).pick(:id)
194
+ return unless disposed_root_id
195
+
196
+ raise IntegrityCheckFailed,
197
+ "Current state for #{actor_reference} depends on disposed root event #{disposed_root_id}. " \
198
+ "Clickwrap refused to delete the existing projection because the reviewed disposition " \
199
+ "removed the statement identity needed to rebuild it."
200
+ end
201
+
202
+ def replay!(event)
203
+ return if event.disposed?
204
+ unless event.digest_verified?
205
+ raise IntegrityCheckFailed,
206
+ "Event #{event.id} failed its digest during state rebuild."
207
+ end
208
+
209
+ if INITIAL_EVENT_TYPES.include?(event.event_type)
210
+ apply!(event)
211
+ return
212
+ end
213
+
214
+ destination = TRANSITION_STATE_BY_EVENT_TYPE[event.event_type]
215
+ return unless destination
216
+
217
+ root = Event.find_by(id: event.root_event_id)
218
+ unless root&.digest_verified?
219
+ raise IntegrityCheckFailed,
220
+ "Lifecycle event #{event.id} does not have a verifiable root event, so its " \
221
+ "statement identity cannot be rebuilt safely."
222
+ end
223
+
224
+ event.statements.each do |statement|
225
+ identity = StatementState.identity_for(
226
+ policy_key: event.policy_key,
227
+ statement_key: statement.statement_key,
228
+ # A withdrawal, expiry, consumption, or revocation may be appended
229
+ # by a system/operator actor. That actor performed the lifecycle
230
+ # transition; it is not the human whose statement is affected.
231
+ # The immutable root event owns the statement identity.
232
+ actor_reference: root.actor_reference,
233
+ tenant_key: root.tenant_key,
234
+ subject_key: root.subject_key,
235
+ represented_party_reference: root.represented_party_reference
236
+ )
237
+ state = StatementState.find_by(identity)
238
+ next unless state
239
+
240
+ transition!(state, to: destination, event: event, at: statement.valid_from)
241
+ end
242
+ end
243
+
244
+ def state_for(event, statement)
245
+ return "exempted" if event.event_type == "exemption"
246
+ return "declined" if statement.action == "declined"
247
+
248
+ "active"
249
+ end
250
+
251
+ def apply_lifecycle_timestamps(state, event, statement)
252
+ return unless state.persisted?
253
+
254
+ # A renewal starts a fresh validity period rather than extending the old
255
+ # one, so the previous expiry never quietly survives.
256
+ return unless statement.action == "renewed"
257
+
258
+ state.effective_at = effective_at_for(event, statement)
259
+ state.withdrawn_at = nil
260
+ state.expires_at = statement.expires_at
261
+ end
262
+
263
+ def event_action_for(state_name)
264
+ case state_name.to_s
265
+ when "withdrawn" then "withdrawn"
266
+ when "superseded" then "superseded"
267
+ when "consumed" then "consumed"
268
+ when "revoked" then "revoked"
269
+ when "corrected" then "corrected"
270
+ when "expired" then "expired"
271
+ else state_name.to_s
272
+ end
273
+ end
274
+
275
+ def document_version_ids_for(event, statement)
276
+ event.documents
277
+ .select { |document| document.statement_key == statement.statement_key }
278
+ .map { |document| document.document_version_id.to_s }
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "openssl"
5
+
6
+ module Clickwrap
7
+ # Digest helpers for document bytes, presentation manifests, receipts, and the
8
+ # optional event chain.
9
+ #
10
+ # What a digest here does and does not mean is part of the public contract.
11
+ # A digest detects that the bytes it covers changed. It does not identify who
12
+ # produced them, when they were produced, or that a party with full control
13
+ # of the database and application could not have rewritten both the bytes and
14
+ # the digest. Stronger claims need the optional independent anchor or
15
+ # timestamp adapters, and even then Clickwrap reports exactly the assurance
16
+ # those adapters supply.
17
+ #
18
+ # SHA-2 is a hash standard (NIST FIPS 180-4,
19
+ # https://csrc.nist.gov/pubs/fips/180-4/upd1/final). It is not a signature,
20
+ # not an identity, and not a time source.
21
+ module Digest
22
+ # Algorithm name => the OpenSSL digest that computes it. Every value stored
23
+ # in evidence carries its algorithm name so a future release can add an
24
+ # algorithm without making old events unverifiable.
25
+ SUPPORTED_ALGORITHMS = {
26
+ "sha256" => "SHA256",
27
+ "sha384" => "SHA384",
28
+ "sha512" => "SHA512"
29
+ }.freeze
30
+
31
+ DEFAULT_ALGORITHM = "sha256"
32
+ HEX_LENGTH_BY_ALGORITHM = {
33
+ "sha256" => 64,
34
+ "sha384" => 96,
35
+ "sha512" => 128
36
+ }.freeze
37
+
38
+ # Digests are written as "<algorithm>:<lowercase hex>" everywhere they
39
+ # appear, so an auditor never has to guess which function produced a bare
40
+ # hex string.
41
+ PREFIXED_PATTERN = /\A(?<algorithm>[a-z0-9]+):(?<value>[0-9a-f]+)\z/
42
+
43
+ class << self
44
+ # Returns "sha256:<hex>" for the given bytes.
45
+ def digest(bytes, algorithm: DEFAULT_ALGORITHM)
46
+ "#{algorithm}:#{hex(bytes, algorithm:)}"
47
+ end
48
+
49
+ # Returns the bare lowercase hex digest.
50
+ def hex(bytes, algorithm: DEFAULT_ALGORITHM)
51
+ OpenSSL::Digest.hexdigest(openssl_name(algorithm), bytes.to_s.b)
52
+ end
53
+
54
+ # Canonicalizes `value` per RFC 8785 and digests the resulting bytes.
55
+ # This is how manifests, compiled policy revisions, and receipts are
56
+ # digested: the digest covers meaning, not formatting.
57
+ def digest_canonical(value, algorithm: DEFAULT_ALGORITHM)
58
+ digest(CanonicalJson.generate(value), algorithm:)
59
+ end
60
+
61
+ # A keyed digest, used where an unkeyed one would be guessable.
62
+ #
63
+ # An IPv4 address is 32 bits (RFC 791), so an unsalted hash of one can be
64
+ # tested by enumerating every address in minutes. Clickwrap therefore
65
+ # binds request evidence to its event with a keyed construction and says
66
+ # plainly that the result is a linkable pseudonymous value, not an
67
+ # anonymous one.
68
+ def keyed_digest(bytes, key:, algorithm: DEFAULT_ALGORITHM)
69
+ raise ArgumentError, "A keyed digest needs a key" if key.nil? || key.to_s.empty?
70
+
71
+ mac = OpenSSL::HMAC.hexdigest(openssl_name(algorithm), key.to_s, bytes.to_s.b)
72
+ "hmac-#{algorithm}:#{mac}"
73
+ end
74
+
75
+ # Compares two digest strings without leaking timing information.
76
+ def secure_compare?(left, right)
77
+ return false if left.nil? || right.nil?
78
+
79
+ OpenSSL.secure_compare(left.to_s, right.to_s)
80
+ end
81
+
82
+ def algorithm_of(prefixed)
83
+ PREFIXED_PATTERN.match(prefixed.to_s)&.[](:algorithm)
84
+ end
85
+
86
+ def supported?(algorithm)
87
+ SUPPORTED_ALGORITHMS.key?(algorithm.to_s)
88
+ end
89
+
90
+ # Whether a stored digest has a supported algorithm prefix and the exact
91
+ # hexadecimal length that algorithm emits. This validates provenance
92
+ # identifiers whose source bytes are intentionally unavailable here; it
93
+ # does not claim the digest matches those unavailable bytes.
94
+ def well_formed?(prefixed)
95
+ match = PREFIXED_PATTERN.match(prefixed.to_s)
96
+ return false unless match
97
+
98
+ expected_length = HEX_LENGTH_BY_ALGORITHM[match[:algorithm]]
99
+ !expected_length.nil? && match[:value].length == expected_length
100
+ end
101
+
102
+ # Verifies that `bytes` still hash to `expected`, which must be a
103
+ # prefixed digest so the algorithm travels with the value.
104
+ def matches?(bytes, expected)
105
+ match = PREFIXED_PATTERN.match(expected.to_s)
106
+ return false unless match
107
+
108
+ algorithm = match[:algorithm]
109
+ return false unless supported?(algorithm)
110
+
111
+ secure_compare?(hex(bytes, algorithm:), match[:value])
112
+ end
113
+
114
+ private
115
+
116
+ def openssl_name(algorithm)
117
+ SUPPORTED_ALGORITHMS.fetch(algorithm.to_s) do
118
+ raise ConfigurationError,
119
+ "#{algorithm.inspect} is not a supported digest algorithm. " \
120
+ "Choose one of: #{SUPPORTED_ALGORITHMS.keys.join(", ")}."
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end