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,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
|