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,978 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The standalone verifier's dependency list is the feature. It requires JSON,
|
|
4
|
+
# the canonicalizer, the digest helpers, the error classes, and the version
|
|
5
|
+
# constants — and nothing else. No Rails, no ActiveRecord, no database, no
|
|
6
|
+
# engine, no host application, no policy source.
|
|
7
|
+
#
|
|
8
|
+
# That is what makes an exported receipt worth exporting. A verifier that had
|
|
9
|
+
# to boot the application which produced the evidence would only ever be able
|
|
10
|
+
# to tell you that the application agrees with itself. This one runs in a bare
|
|
11
|
+
# `ruby -r` on a machine that has never heard of your product, five years from
|
|
12
|
+
# now, from a file and a folder of documents.
|
|
13
|
+
#
|
|
14
|
+
# `require_relative` rather than `require` on purpose: the four files below sit
|
|
15
|
+
# next to this one in the gem, and resolving them by path means the verifier
|
|
16
|
+
# works whether or not anyone remembered to put `lib` on the load path.
|
|
17
|
+
require "json"
|
|
18
|
+
|
|
19
|
+
require_relative "errors"
|
|
20
|
+
require_relative "version"
|
|
21
|
+
require_relative "canonical_json"
|
|
22
|
+
require_relative "digest"
|
|
23
|
+
|
|
24
|
+
module Clickwrap
|
|
25
|
+
# Verifies an exported canonical receipt.
|
|
26
|
+
#
|
|
27
|
+
# ===========================================================================
|
|
28
|
+
# WHAT A SUCCESSFUL VERIFICATION HERE DOES AND DOES NOT ESTABLISH.
|
|
29
|
+
#
|
|
30
|
+
# It DOES establish:
|
|
31
|
+
#
|
|
32
|
+
# * that the file is well-formed JSON in a receipt schema this verifier
|
|
33
|
+
# knows by name;
|
|
34
|
+
# * that its bytes are canonical under RFC 8785, so two readers digest the
|
|
35
|
+
# same thing;
|
|
36
|
+
# * that the digest the receipt carries matches the receipt body it
|
|
37
|
+
# travels with, so accidental or ordinary modification of those bytes is
|
|
38
|
+
# detected;
|
|
39
|
+
# * that each document file you supplied hashes to the digest the receipt
|
|
40
|
+
# recorded for it, so the bundle is internally consistent; and
|
|
41
|
+
# * that any chain links present are consistent with each other.
|
|
42
|
+
#
|
|
43
|
+
# It does NOT establish:
|
|
44
|
+
#
|
|
45
|
+
# * that the receipt was not fabricated. A self-contained file verifying
|
|
46
|
+
# against itself proves internal consistency and nothing about origin. A
|
|
47
|
+
# party who controlled the application, the database, and the export
|
|
48
|
+
# could have produced every byte in it, including the digest, and this
|
|
49
|
+
# verifier would say "verified" — because the only thing it can compare
|
|
50
|
+
# the bytes against is the bytes;
|
|
51
|
+
# * WHEN anything happened. `recorded_at_by_server` is a time an
|
|
52
|
+
# application server wrote down. It is not attested by anyone else;
|
|
53
|
+
# * WHO acted. An actor reference identifies a record in someone's
|
|
54
|
+
# database, not a person;
|
|
55
|
+
# * that any of it is legally sufficient, adequately presented, or
|
|
56
|
+
# admissible anywhere. That is not a property of a file.
|
|
57
|
+
#
|
|
58
|
+
# Origin and time evidence come from things this verifier cannot supply on
|
|
59
|
+
# its own: an independent publication holding the exact event-chain snapshot somewhere the
|
|
60
|
+
# database operator does not control, an RFC 3161 or trust-service timestamp,
|
|
61
|
+
# or a provider's own signed receipt. Where those exist, they are reported as
|
|
62
|
+
# exactly the assurance they supply, and where they do not, their absence is
|
|
63
|
+
# visible rather than papered over by a green check mark.
|
|
64
|
+
# ===========================================================================
|
|
65
|
+
module ReceiptVerifier
|
|
66
|
+
# Every receipt schema this verifier can read.
|
|
67
|
+
#
|
|
68
|
+
# THIS LIST ONLY EVER GROWS. A released evidence format is permanent: a new
|
|
69
|
+
# gem version may stop *creating* an old schema, but it must never stop
|
|
70
|
+
# *verifying* one, because the receipts already exported under it are out
|
|
71
|
+
# in the world and their whole value is that they still check out years
|
|
72
|
+
# later. A format change means a new entry here and a new branch of
|
|
73
|
+
# verification logic — never a silent reinterpretation of an old name.
|
|
74
|
+
KNOWN_SCHEMAS = ["clickwrap.receipt.v1"].freeze
|
|
75
|
+
|
|
76
|
+
# The key that holds the digest, and which is therefore excluded from the
|
|
77
|
+
# bytes the digest covers. See `body_covered_by_digest` for the precise
|
|
78
|
+
# rule and the reason it has to work this way.
|
|
79
|
+
INTEGRITY_KEY = "integrity"
|
|
80
|
+
|
|
81
|
+
# `receipt_digest`, not `event_digest`. The two are different values and
|
|
82
|
+
# answer different questions: `event_digest` was computed over the embedded
|
|
83
|
+
# event's canonical body when the event was written; `receipt_digest` covers
|
|
84
|
+
# the entire exported projection. The standalone verifier checks both (or a
|
|
85
|
+
# documented disposition in place of a removed event payload), and excludes
|
|
86
|
+
# only this self-referential receipt field when checking the latter.
|
|
87
|
+
DIGEST_KEY = "receipt_digest"
|
|
88
|
+
|
|
89
|
+
# One thing that was looked at, and what was found. `passed` is nil for a
|
|
90
|
+
# check that could not be run — a document whose bytes were not supplied is
|
|
91
|
+
# neither a pass nor a failure, and collapsing the three states into two is
|
|
92
|
+
# how "we did not look" becomes "we looked and it was fine".
|
|
93
|
+
Check = Data.define(:name, :passed, :detail) do
|
|
94
|
+
def passed? = passed == true
|
|
95
|
+
def failed? = passed == false
|
|
96
|
+
def skipped? = passed.nil?
|
|
97
|
+
def to_s = "#{status_word} #{name}: #{detail}"
|
|
98
|
+
|
|
99
|
+
def status_word
|
|
100
|
+
return "ok" if passed?
|
|
101
|
+
return "FAILED" if failed?
|
|
102
|
+
|
|
103
|
+
"skipped"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The result object. `success?` is true only when every check that ran
|
|
108
|
+
# passed; a skipped check never makes a result succeed and never makes it
|
|
109
|
+
# fail, it just stays visible in `checks`.
|
|
110
|
+
class Result
|
|
111
|
+
attr_reader :schema, :checks
|
|
112
|
+
|
|
113
|
+
def initialize(schema:, checks:)
|
|
114
|
+
@schema = schema
|
|
115
|
+
@checks = checks.freeze
|
|
116
|
+
freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def verified? = failures.empty? && skipped.empty?
|
|
120
|
+
alias success? verified?
|
|
121
|
+
|
|
122
|
+
def failed? = failures.any?
|
|
123
|
+
def incomplete? = failures.empty? && skipped.any?
|
|
124
|
+
|
|
125
|
+
def status
|
|
126
|
+
if failed?
|
|
127
|
+
"failed"
|
|
128
|
+
else
|
|
129
|
+
(incomplete? ? "incomplete" : "verified")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def failures = checks.select(&:failed?)
|
|
134
|
+
def skipped = checks.select(&:skipped?)
|
|
135
|
+
def passed = checks.select(&:passed?)
|
|
136
|
+
|
|
137
|
+
def to_h
|
|
138
|
+
{
|
|
139
|
+
"success" => success?,
|
|
140
|
+
"status" => status,
|
|
141
|
+
"schema" => schema,
|
|
142
|
+
"verifier_version" => Clickwrap::VERIFIER_VERSION,
|
|
143
|
+
"checks" => checks.map do |check|
|
|
144
|
+
{ "name" => check.name, "passed" => check.passed, "detail" => check.detail }
|
|
145
|
+
end
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def to_s
|
|
150
|
+
lines = ["#{status.upcase} (#{schema || "unknown schema"})"]
|
|
151
|
+
lines.concat(checks.map { |check| " #{check}" })
|
|
152
|
+
lines << " note: #{PROVES}"
|
|
153
|
+
lines.join("\n")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def inspect = "#<Clickwrap::ReceiptVerifier::Result #{status}>"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Printed with every result, so nobody has to go looking for the caveat.
|
|
160
|
+
PROVES =
|
|
161
|
+
"A successful verification shows this receipt is internally consistent and its bytes have " \
|
|
162
|
+
"not changed since the digest was taken. It does not show who produced it, when, or that " \
|
|
163
|
+
"a party controlling every source could not have fabricated the whole file. Independent " \
|
|
164
|
+
"anchors and provider signatures are what add origin and time evidence."
|
|
165
|
+
|
|
166
|
+
class << self
|
|
167
|
+
# Verifies `canonical_json_string`.
|
|
168
|
+
#
|
|
169
|
+
# `documents:` maps a document key (or "key@version") to the exact bytes
|
|
170
|
+
# of that document. Any document not supplied is reported `not_supplied`
|
|
171
|
+
# rather than assumed fine.
|
|
172
|
+
def verify(canonical_json_string, documents: {})
|
|
173
|
+
checks = []
|
|
174
|
+
schema = nil
|
|
175
|
+
|
|
176
|
+
body = parse(canonical_json_string, checks)
|
|
177
|
+
return Result.new(schema: nil, checks: checks) if body.nil?
|
|
178
|
+
|
|
179
|
+
schema = body["schema"]
|
|
180
|
+
return Result.new(schema: schema, checks: checks) unless check_schema(schema, checks)
|
|
181
|
+
|
|
182
|
+
check_canonical_bytes(canonical_json_string, body, checks)
|
|
183
|
+
check_receipt_digest(body, checks)
|
|
184
|
+
check_event_digest(body, checks)
|
|
185
|
+
check_lifecycle_successors(body, checks)
|
|
186
|
+
check_integrity_attestations(body, checks)
|
|
187
|
+
check_documents(body, documents, checks)
|
|
188
|
+
check_chain_linkage(body, checks)
|
|
189
|
+
|
|
190
|
+
Result.new(schema: schema, checks: checks)
|
|
191
|
+
rescue StandardError => error
|
|
192
|
+
checks << Check.new(
|
|
193
|
+
name: "verifier_input",
|
|
194
|
+
passed: false,
|
|
195
|
+
detail: "The receipt could not be verified safely because #{error.class}: #{error.message}"
|
|
196
|
+
)
|
|
197
|
+
Result.new(schema: schema, checks: checks)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Same, but raises rather than returning a failed result. Useful in a
|
|
201
|
+
# script where a bad receipt should stop the run.
|
|
202
|
+
#
|
|
203
|
+
# An unknown schema gets its own error class, because it is a different
|
|
204
|
+
# problem with a different fix: the receipt is probably fine and this
|
|
205
|
+
# verifier is too old to read it.
|
|
206
|
+
def verify!(canonical_json_string, documents: {})
|
|
207
|
+
result = verify(canonical_json_string, documents: documents)
|
|
208
|
+
return result if result.success?
|
|
209
|
+
|
|
210
|
+
unknown = result.failures.find { |check| check.name == "known_schema" }
|
|
211
|
+
raise UnknownReceiptSchema, unknown.detail if unknown
|
|
212
|
+
|
|
213
|
+
raise ReceiptInvalid, result.to_s
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def known_schema?(schema) = KNOWN_SCHEMAS.include?(schema.to_s)
|
|
217
|
+
|
|
218
|
+
# The exact bytes a receipt's `integrity.receipt_digest` covers: the whole
|
|
219
|
+
# receipt with only that self-referential field removed. Every other
|
|
220
|
+
# integrity claim—including event digest, chain position, tier, and claim
|
|
221
|
+
# sentence—is covered and cannot be edited for free.
|
|
222
|
+
#
|
|
223
|
+
# The exclusion is not a convenience. A digest cannot cover itself: the
|
|
224
|
+
# moment the digest is written into the object, the object's bytes change
|
|
225
|
+
# and the digest no longer matches them, and no amount of recomputation
|
|
226
|
+
# converges. So the digest is taken over the body before this one field is
|
|
227
|
+
# attached, and verification reproduces that by removing the field again.
|
|
228
|
+
#
|
|
229
|
+
# The exclusion is precisely `integrity.receipt_digest` and nothing else.
|
|
230
|
+
# Every other key — including the rest of `integrity`,
|
|
231
|
+
# `verifier_instructions`, and any host
|
|
232
|
+
# `x_`-prefixed extension — is inside the digest. A key that were excluded
|
|
233
|
+
# without being named here would be a key anyone could edit freely, which
|
|
234
|
+
# is the opposite of the point.
|
|
235
|
+
def body_covered_by_digest(body)
|
|
236
|
+
covered = deep_copy(body)
|
|
237
|
+
integrity = covered[INTEGRITY_KEY]
|
|
238
|
+
integrity.delete(DIGEST_KEY) if integrity.is_a?(Hash)
|
|
239
|
+
covered
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def parse(text, checks)
|
|
245
|
+
if text.nil? || text.to_s.empty?
|
|
246
|
+
checks << Check.new(name: "json_parses", passed: false, detail: "The receipt was empty.")
|
|
247
|
+
return nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
body = JSON.parse(text.to_s, object_class: UniqueKeyHash)
|
|
251
|
+
|
|
252
|
+
unless body.is_a?(Hash)
|
|
253
|
+
checks << Check.new(name: "json_parses", passed: false,
|
|
254
|
+
detail: "A receipt is a JSON object; this one is a #{body.class}.")
|
|
255
|
+
return nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
checks << Check.new(name: "json_parses", passed: true, detail: "Parsed as a JSON object.")
|
|
259
|
+
body
|
|
260
|
+
rescue JSON::ParserError, DuplicateJsonKey => error
|
|
261
|
+
checks << Check.new(name: "json_parses", passed: false,
|
|
262
|
+
detail: "The receipt is not valid JSON: #{error.message}")
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# An unknown schema fails honestly and stops. It is NOT read on a
|
|
267
|
+
# best-effort basis: guessing at the meaning of a format this verifier
|
|
268
|
+
# has never seen would produce a confident answer about a document it
|
|
269
|
+
# does not understand, which is worse than no answer at all.
|
|
270
|
+
def check_schema(schema, checks)
|
|
271
|
+
if known_schema?(schema)
|
|
272
|
+
checks << Check.new(name: "known_schema", passed: true,
|
|
273
|
+
detail: "Schema #{schema} is one this verifier knows.")
|
|
274
|
+
return true
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
checks << Check.new(
|
|
278
|
+
name: "known_schema", passed: false,
|
|
279
|
+
detail: "Schema #{schema.inspect} is not one this verifier knows " \
|
|
280
|
+
"(#{KNOWN_SCHEMAS.join(", ")}). Verification stopped rather than guessing at " \
|
|
281
|
+
"the meaning of a newer format. Use a Clickwrap release that lists this schema."
|
|
282
|
+
)
|
|
283
|
+
false
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def check_canonical_bytes(text, body, checks)
|
|
287
|
+
canonical = CanonicalJson.generate(body)
|
|
288
|
+
|
|
289
|
+
if canonical == text.to_s
|
|
290
|
+
checks << Check.new(name: "canonical_bytes", passed: true,
|
|
291
|
+
detail: "The bytes are already canonical under RFC 8785.")
|
|
292
|
+
return
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
checks << Check.new(
|
|
296
|
+
name: "canonical_bytes", passed: false,
|
|
297
|
+
detail: "The supplied bytes are not in canonical form; they were re-canonicalized per " \
|
|
298
|
+
"RFC 8785 for the remaining checks, but this file is not the exact canonical " \
|
|
299
|
+
"receipt artifact Clickwrap exports."
|
|
300
|
+
)
|
|
301
|
+
rescue CanonicalJson::SerializationError => error
|
|
302
|
+
checks << Check.new(name: "canonical_bytes", passed: false,
|
|
303
|
+
detail: "The receipt cannot be canonicalized: #{error.message}")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def check_receipt_digest(body, checks)
|
|
307
|
+
integrity = body[INTEGRITY_KEY]
|
|
308
|
+
|
|
309
|
+
unless integrity.is_a?(Hash) && integrity[DIGEST_KEY].to_s != ""
|
|
310
|
+
checks << Check.new(
|
|
311
|
+
name: "receipt_digest", passed: false,
|
|
312
|
+
detail: "This receipt carries no integrity.#{DIGEST_KEY}, so there is nothing to " \
|
|
313
|
+
"check the body against."
|
|
314
|
+
)
|
|
315
|
+
return
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
recorded = integrity[DIGEST_KEY].to_s
|
|
319
|
+
algorithm = Digest.algorithm_of(recorded)
|
|
320
|
+
|
|
321
|
+
unless algorithm && Digest.supported?(algorithm)
|
|
322
|
+
checks << Check.new(
|
|
323
|
+
name: "receipt_digest", passed: false,
|
|
324
|
+
detail: "integrity.#{DIGEST_KEY} is #{recorded.inspect}, which does not name a " \
|
|
325
|
+
"digest algorithm this verifier supports (#{Digest::SUPPORTED_ALGORITHMS.keys.join(", ")})."
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
computed = Digest.digest_canonical(body_covered_by_digest(body), algorithm: algorithm)
|
|
331
|
+
|
|
332
|
+
checks << if Digest.secure_compare?(computed, recorded)
|
|
333
|
+
Check.new(
|
|
334
|
+
name: "receipt_digest", passed: true,
|
|
335
|
+
detail: "The recorded #{algorithm} digest matches the canonicalized receipt body " \
|
|
336
|
+
"with only integrity.#{DIGEST_KEY} excluded."
|
|
337
|
+
)
|
|
338
|
+
else
|
|
339
|
+
Check.new(
|
|
340
|
+
name: "receipt_digest", passed: false,
|
|
341
|
+
detail: "The recorded digest #{recorded} does not match the body. Computed #{computed} " \
|
|
342
|
+
"over the canonicalized receipt with only integrity.#{DIGEST_KEY} excluded. Something " \
|
|
343
|
+
"about these bytes changed after the digest was taken."
|
|
344
|
+
)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def check_event_digest(body, checks)
|
|
349
|
+
integrity = body[INTEGRITY_KEY]
|
|
350
|
+
event_body = body["event"]
|
|
351
|
+
recorded = integrity.is_a?(Hash) ? integrity["event_digest"].to_s : ""
|
|
352
|
+
|
|
353
|
+
unless event_body.is_a?(Hash) && !recorded.empty?
|
|
354
|
+
checks << Check.new(
|
|
355
|
+
name: "event_digest", passed: false,
|
|
356
|
+
detail: "The receipt must carry both event and integrity.event_digest so the " \
|
|
357
|
+
"application's recorded event can be re-derived independently."
|
|
358
|
+
)
|
|
359
|
+
return
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
algorithm = Digest.algorithm_of(recorded)
|
|
363
|
+
unless algorithm && Digest.supported?(algorithm)
|
|
364
|
+
checks << Check.new(name: "event_digest", passed: false,
|
|
365
|
+
detail: "integrity.event_digest does not use a supported algorithm.")
|
|
366
|
+
return
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
if body.dig("retention", "core_event_disposed_at").to_s != ""
|
|
370
|
+
check_documented_core_disposition(body, event_body, recorded, checks)
|
|
371
|
+
return
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
computed = Digest.digest_canonical(event_body, algorithm: algorithm)
|
|
375
|
+
mismatches = event_projection_mismatches(body, event_body)
|
|
376
|
+
projection_matches = mismatches.empty?
|
|
377
|
+
|
|
378
|
+
checks << if Digest.secure_compare?(computed, recorded) && projection_matches
|
|
379
|
+
Check.new(name: "event_digest", passed: true,
|
|
380
|
+
detail: "The embedded canonical event and receipt projection match " \
|
|
381
|
+
"the recorded #{algorithm} event digest.")
|
|
382
|
+
else
|
|
383
|
+
Check.new(
|
|
384
|
+
name: "event_digest",
|
|
385
|
+
passed: false,
|
|
386
|
+
detail: "The embedded event digest or its receipt projection does not match " \
|
|
387
|
+
"the recorded event evidence. Mismatched projections: " \
|
|
388
|
+
"#{mismatches.join(", ").empty? ? "(embedded digest)" : mismatches.join(", ")}"
|
|
389
|
+
)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# A retention run deliberately removes the original event payload, so a
|
|
394
|
+
# later export cannot rederive its original digest without defeating the
|
|
395
|
+
# deletion. That is neither a successful digest check nor evidence of
|
|
396
|
+
# tampering. It is an explicit incomplete check, but only after the
|
|
397
|
+
# retained tombstone and the independently digest-checked disposition
|
|
398
|
+
# successor agree on exactly what was disposed, when, and which original
|
|
399
|
+
# digest remains as the historical anchor.
|
|
400
|
+
def check_documented_core_disposition(body, event_body, recorded_digest, checks)
|
|
401
|
+
disposed_at = body.dig("retention", "core_event_disposed_at").to_s
|
|
402
|
+
event_id = body["event_id"].to_s
|
|
403
|
+
problems = event_projection_mismatches(body, event_body)
|
|
404
|
+
.map { |field| "retained receipt projection differs at #{field}" }
|
|
405
|
+
|
|
406
|
+
problems.concat(disposed_tombstone_problems(body, event_body))
|
|
407
|
+
|
|
408
|
+
successors = Array(body.dig("lifecycle", "successors"))
|
|
409
|
+
dispositions = successors.select do |successor|
|
|
410
|
+
successor.is_a?(Hash) &&
|
|
411
|
+
successor.dig("event", "protected_outcome", "core_event_disposition").is_a?(Hash)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
if dispositions.one?
|
|
415
|
+
successor_event = dispositions.first["event"]
|
|
416
|
+
facts = successor_event.dig("protected_outcome", "core_event_disposition")
|
|
417
|
+
problems << "disposition names a different event" unless facts["event_id"].to_s == event_id
|
|
418
|
+
problems << "disposition names a different original event digest" unless
|
|
419
|
+
facts["original_event_digest"].to_s == recorded_digest
|
|
420
|
+
problems << "disposition records a different disposal time" unless facts["disposed_at"].to_s == disposed_at
|
|
421
|
+
unless successor_event["event_type"] == "disposition"
|
|
422
|
+
problems << "disposition successor has the wrong event type"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
linked = successor_event["root_event_id"].to_s == event_id ||
|
|
426
|
+
successor_event["predecessor_event_id"].to_s == event_id
|
|
427
|
+
problems << "disposition successor is not linked to the disposed event" unless linked
|
|
428
|
+
else
|
|
429
|
+
problems << if dispositions.empty?
|
|
430
|
+
"no core-event disposition successor is present"
|
|
431
|
+
else
|
|
432
|
+
"more than one core-event disposition successor is present"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
checks << if problems.empty?
|
|
437
|
+
Check.new(
|
|
438
|
+
name: "event_digest",
|
|
439
|
+
passed: nil,
|
|
440
|
+
detail: "The original core event payload was lawfully disposed at #{disposed_at}. " \
|
|
441
|
+
"The retained tombstone and documented disposition identify its original " \
|
|
442
|
+
"digest #{recorded_digest}, but that digest cannot be re-derived after the " \
|
|
443
|
+
"covered payload was deleted."
|
|
444
|
+
)
|
|
445
|
+
else
|
|
446
|
+
Check.new(
|
|
447
|
+
name: "event_digest",
|
|
448
|
+
passed: false,
|
|
449
|
+
detail: "The receipt claims a core-event disposition, but its retained evidence is " \
|
|
450
|
+
"inconsistent: #{problems.join("; ")}."
|
|
451
|
+
)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def disposed_tombstone_problems(body, event_body)
|
|
456
|
+
problems = []
|
|
457
|
+
problems << "receipt acts remain after disposition" unless body["acts"] == []
|
|
458
|
+
problems << "receipt documents remain after disposition" unless body["documents"] == []
|
|
459
|
+
problems << "embedded event acts remain after disposition" unless event_body["acts"] == []
|
|
460
|
+
problems << "embedded event documents remain after disposition" unless event_body["documents"] == []
|
|
461
|
+
problems << "receipt actor reference remains after disposition" unless body.dig("actor",
|
|
462
|
+
"reference").to_s.empty?
|
|
463
|
+
problems << "embedded actor reference remains after disposition" unless
|
|
464
|
+
event_body.dig("actor", "reference").to_s.empty?
|
|
465
|
+
problems << "receipt presentation remains after disposition" unless body["presentation"].nil?
|
|
466
|
+
problems << "embedded presentation remains after disposition" unless event_body["presentation"].nil?
|
|
467
|
+
problems << "receipt protected outcome remains after disposition" unless body["outcome"].nil?
|
|
468
|
+
problems << "embedded protected outcome remains after disposition" unless event_body["protected_outcome"].nil?
|
|
469
|
+
problems
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def check_lifecycle_successors(body, checks)
|
|
473
|
+
successors = body.dig("lifecycle", "successors")
|
|
474
|
+
return if successors.nil?
|
|
475
|
+
|
|
476
|
+
unless successors.is_a?(Array)
|
|
477
|
+
checks << Check.new(name: "lifecycle_successors", passed: false,
|
|
478
|
+
detail: "lifecycle.successors must be an array.")
|
|
479
|
+
return
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
seen = {}
|
|
483
|
+
successors.each_with_index do |successor, index|
|
|
484
|
+
check_lifecycle_successor(body, successor, index, seen, checks)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def check_lifecycle_successor(root_body, successor, index, seen, checks)
|
|
489
|
+
name = "lifecycle_successor:#{index}"
|
|
490
|
+
unless successor.is_a?(Hash) && successor["event"].is_a?(Hash)
|
|
491
|
+
checks << Check.new(name: name, passed: false,
|
|
492
|
+
detail: "The successor must embed its canonical event body.")
|
|
493
|
+
return
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
event = successor["event"]
|
|
497
|
+
event_id = event["event_id"].to_s
|
|
498
|
+
name = "lifecycle_successor:#{event_id.empty? ? index : event_id}"
|
|
499
|
+
recorded = successor["event_digest"].to_s
|
|
500
|
+
algorithm = Digest.algorithm_of(recorded)
|
|
501
|
+
problems = []
|
|
502
|
+
problems << "event id is missing" if event_id.empty?
|
|
503
|
+
problems << "event id is duplicated" if seen[event_id]
|
|
504
|
+
seen[event_id] = true unless event_id.empty?
|
|
505
|
+
problems << "event digest is missing or unsupported" unless algorithm && Digest.supported?(algorithm)
|
|
506
|
+
|
|
507
|
+
if algorithm && Digest.supported?(algorithm)
|
|
508
|
+
computed = Digest.digest_canonical(event, algorithm: algorithm)
|
|
509
|
+
problems << "event digest does not match" unless Digest.secure_compare?(computed, recorded)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
projections = {
|
|
513
|
+
"event_id" => [successor["event_id"], event["event_id"]],
|
|
514
|
+
"event_type" => [successor["event_type"], event["event_type"]],
|
|
515
|
+
"recorded_at_by_server" => [successor["recorded_at_by_server"],
|
|
516
|
+
event["recorded_at_by_server"]],
|
|
517
|
+
"reason" => [successor["reason"], event["reason"]]
|
|
518
|
+
}
|
|
519
|
+
mismatches = projections.filter_map { |field, values| field unless values[0] == values[1] }
|
|
520
|
+
problems << "summary differs from embedded event (#{mismatches.join(", ")})" if mismatches.any?
|
|
521
|
+
|
|
522
|
+
root_id = root_body["event_id"].to_s
|
|
523
|
+
linked = event["root_event_id"].to_s == root_id || event["predecessor_event_id"].to_s == root_id
|
|
524
|
+
problems << "event is not linked to receipt #{root_id}" unless linked
|
|
525
|
+
|
|
526
|
+
checks << Check.new(
|
|
527
|
+
name: name,
|
|
528
|
+
passed: problems.empty?,
|
|
529
|
+
detail: if problems.empty?
|
|
530
|
+
"The embedded successor event and its summary match #{recorded}."
|
|
531
|
+
else
|
|
532
|
+
problems.join("; ")
|
|
533
|
+
end
|
|
534
|
+
)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def check_integrity_attestations(body, checks)
|
|
538
|
+
attestations = body.dig("integrity", "attestations")
|
|
539
|
+
expected_tier = "baseline"
|
|
540
|
+
|
|
541
|
+
if attestations
|
|
542
|
+
unless attestations.is_a?(Array)
|
|
543
|
+
checks << Check.new(name: "integrity_attestations", passed: false,
|
|
544
|
+
detail: "integrity.attestations must be an array.")
|
|
545
|
+
return
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
verified_kinds = attestations.filter_map.with_index do |attestation, index|
|
|
549
|
+
check = check_integrity_attestation(body, attestation, index)
|
|
550
|
+
checks << check
|
|
551
|
+
attestation["kind"] if check.passed? && attestation["state"] == "verified"
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
timestamp = attestations.find do |attestation|
|
|
555
|
+
verified_kinds.include?("third_party_timestamp") &&
|
|
556
|
+
attestation.is_a?(Hash) && attestation["kind"] == "third_party_timestamp" &&
|
|
557
|
+
attestation.dig("adapter_capabilities", "independently_verifiable") == true
|
|
558
|
+
end
|
|
559
|
+
external_anchor = attestations.find do |attestation|
|
|
560
|
+
verified_kinds.include?("event_anchor") &&
|
|
561
|
+
attestation.is_a?(Hash) && attestation["kind"] == "event_anchor" &&
|
|
562
|
+
attestation.dig("adapter_capabilities", "publishes_outside_primary_database") == true
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
expected_tier = if timestamp
|
|
566
|
+
"third_party_timestamp"
|
|
567
|
+
elsif external_anchor
|
|
568
|
+
"external_event_anchoring"
|
|
569
|
+
elsif body.dig("integrity", "chain_scope")
|
|
570
|
+
"chained_history"
|
|
571
|
+
else
|
|
572
|
+
"baseline"
|
|
573
|
+
end
|
|
574
|
+
elsif body.dig("integrity", "chain_scope")
|
|
575
|
+
expected_tier = "chained_history"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
actual_tier = body.dig("integrity", "tier").to_s
|
|
579
|
+
checks << Check.new(
|
|
580
|
+
name: "integrity_tier",
|
|
581
|
+
passed: actual_tier == expected_tier,
|
|
582
|
+
detail: if actual_tier == expected_tier
|
|
583
|
+
"The advertised #{actual_tier} tier matches the verified evidence in this receipt."
|
|
584
|
+
else
|
|
585
|
+
"The receipt advertises #{actual_tier.inspect}, but its verifiable evidence supports " \
|
|
586
|
+
"#{expected_tier.inspect}."
|
|
587
|
+
end
|
|
588
|
+
)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def check_integrity_attestation(body, attestation, index)
|
|
592
|
+
name = "integrity_attestation:#{index}"
|
|
593
|
+
unless attestation.is_a?(Hash)
|
|
594
|
+
return Check.new(name: name, passed: false,
|
|
595
|
+
detail: "The attestation is not a JSON object.")
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
kind = attestation["kind"].to_s
|
|
599
|
+
state = attestation["state"].to_s
|
|
600
|
+
name = "integrity_attestation:#{kind.empty? ? index : kind}:#{index}"
|
|
601
|
+
recorded = attestation["attestation_digest"].to_s
|
|
602
|
+
algorithm = Digest.algorithm_of(recorded)
|
|
603
|
+
body_without_digest = attestation.except("attestation_digest")
|
|
604
|
+
problems = []
|
|
605
|
+
problems << "attestation kind is not recognized" unless
|
|
606
|
+
%w[event_anchor third_party_timestamp].include?(kind)
|
|
607
|
+
problems << "attestation state is not recognized" unless
|
|
608
|
+
%w[verified issued_unverified unavailable failed].include?(state)
|
|
609
|
+
|
|
610
|
+
problems << "attestation digest is missing or unsupported" unless algorithm && Digest.supported?(algorithm)
|
|
611
|
+
if algorithm && Digest.supported?(algorithm)
|
|
612
|
+
computed = Digest.digest_canonical(body_without_digest, algorithm: algorithm)
|
|
613
|
+
problems << "attestation digest does not match" unless Digest.secure_compare?(computed, recorded)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
problems << "attestation belongs to a different event" unless
|
|
617
|
+
attestation["event_id"].to_s == body["event_id"].to_s
|
|
618
|
+
problems << "attestation covers a different event digest" unless
|
|
619
|
+
attestation["subject_digest"].to_s == body.dig("integrity", "event_digest").to_s
|
|
620
|
+
|
|
621
|
+
if kind == "event_anchor"
|
|
622
|
+
problems << "anchor chain scope differs from this event" unless
|
|
623
|
+
attestation["chain_scope"].to_s == body.dig("integrity", "chain_scope").to_s
|
|
624
|
+
problems << "anchor chain sequence differs from this event" unless
|
|
625
|
+
attestation["chain_sequence"] == body.dig("integrity", "chain_sequence")
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
if state == "verified"
|
|
629
|
+
verification = attestation["verification"]
|
|
630
|
+
unless verification.is_a?(Hash) && verification["checked"] == true &&
|
|
631
|
+
verification["verified"] == true
|
|
632
|
+
problems << "state is verified but the recorded adapter verification is not"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
Check.new(
|
|
637
|
+
name: name,
|
|
638
|
+
passed: problems.empty?,
|
|
639
|
+
detail: if problems.empty?
|
|
640
|
+
"The attestation record is digest-bound to this exact event. Its provider claim is " \
|
|
641
|
+
"reported as recorded; cryptographic provider-token verification remains adapter-specific."
|
|
642
|
+
else
|
|
643
|
+
problems.join("; ")
|
|
644
|
+
end
|
|
645
|
+
)
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Every duplicated immutable fact must agree with the embedded canonical
|
|
649
|
+
# event. Checking only ids, acts, and documents would let someone edit the
|
|
650
|
+
# projected actor, authority, subject, outcome, provider, presentation,
|
|
651
|
+
# retention, chain, or timestamps and recompute the self-contained receipt
|
|
652
|
+
# digest while the protected event digest continued to pass.
|
|
653
|
+
def event_projection_mismatches(body, event)
|
|
654
|
+
comparisons = {
|
|
655
|
+
"event_id" => [body["event_id"], event["event_id"]],
|
|
656
|
+
"event_type" => [body["event_type"], event["event_type"]],
|
|
657
|
+
"policy.key" => [body.dig("policy", "key"), event.dig("policy", "key")],
|
|
658
|
+
"policy.revision" => [body.dig("policy", "revision"), event.dig("policy", "revision")],
|
|
659
|
+
"policy.retention_class" => [body.dig("policy", "retention_class"), event.dig("retention", "class")],
|
|
660
|
+
"actor.reference" => [body.dig("actor", "reference"), event.dig("actor", "reference")],
|
|
661
|
+
"actor.attribution" => [body.dig("actor", "attribution"), event.dig("actor", "attribution")],
|
|
662
|
+
"actor.snapshot" => [body.dig("actor", "snapshot"), event.dig("actor", "snapshot")],
|
|
663
|
+
"actor.authentication_method" => [body.dig("actor", "authentication_method"),
|
|
664
|
+
event["authentication_method"]],
|
|
665
|
+
"actor.tenant" => [body.dig("actor", "tenant"), event["tenant"]],
|
|
666
|
+
"actor.subject" => [body.dig("actor", "subject"), event["subject"]],
|
|
667
|
+
"actor.acting_for" => [body.dig("actor", "acting_for"), represented_party_projection(event)],
|
|
668
|
+
"acts" => [body["acts"], event["acts"]],
|
|
669
|
+
"documents" => [body["documents"], event["documents"]],
|
|
670
|
+
"presentation" => [fixed_presentation_projection(body["presentation"]),
|
|
671
|
+
presentation_projection(event)],
|
|
672
|
+
"outcome" => [body["outcome"], event["protected_outcome"]],
|
|
673
|
+
"provider" => [fixed_provider_projection(body["provider"]), provider_projection(event)],
|
|
674
|
+
"lifecycle.root_event_id" => [body.dig("lifecycle", "root_event_id"), event["root_event_id"]],
|
|
675
|
+
"lifecycle.predecessor_event_id" => [body.dig("lifecycle", "predecessor_event_id"),
|
|
676
|
+
event["predecessor_event_id"]],
|
|
677
|
+
"retention.core_event_retained_until" => [body.dig("retention", "core_event_retained_until"),
|
|
678
|
+
event.dig("retention", "retain_core_event_until")],
|
|
679
|
+
"retention.retention_rule" => [body.dig("retention", "retention_rule"),
|
|
680
|
+
event.dig("retention", "rule")],
|
|
681
|
+
"integrity.chain" => [receipt_chain_projection(body), event["chain"]],
|
|
682
|
+
"integrity.request_evidence" => [receipt_request_evidence_projection(body),
|
|
683
|
+
event["request_evidence"]],
|
|
684
|
+
"recorded_at_by_server" => [body["recorded_at_by_server"], event["recorded_at_by_server"]],
|
|
685
|
+
"occurred_at" => [body["occurred_at"], event["occurred_at"]],
|
|
686
|
+
"system.gem_version" => [body.dig("system", "gem_version"), event["gem_version"]],
|
|
687
|
+
"system.application_version" => [body.dig("system", "application_version"),
|
|
688
|
+
event["application_version"]],
|
|
689
|
+
"system.template_version" => [body.dig("system", "template_version"), event["template_version"]],
|
|
690
|
+
"system.canonical_schema_version" => [body.dig("system", "canonical_schema_version"), event["schema"]]
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
comparisons.filter_map { |name, pair| name unless pair[0] == pair[1] }
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def represented_party_projection(event)
|
|
697
|
+
represented = event.dig("actor", "represented_party")
|
|
698
|
+
return nil unless represented
|
|
699
|
+
|
|
700
|
+
authority = event.dig("actor", "authority") || {}
|
|
701
|
+
compact_hash(
|
|
702
|
+
"type" => represented["type"],
|
|
703
|
+
"reference" => represented["reference"],
|
|
704
|
+
"authority_source" => authority["source"],
|
|
705
|
+
"authority_role" => authority["role"],
|
|
706
|
+
"authority_verified_at" => authority["verified_at"],
|
|
707
|
+
"authority_details" => authority["details"]
|
|
708
|
+
)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def fixed_presentation_projection(presentation)
|
|
712
|
+
presentation&.except("proves")
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def presentation_projection(event)
|
|
716
|
+
presentation = event["presentation"]
|
|
717
|
+
return nil unless presentation
|
|
718
|
+
|
|
719
|
+
manifest = presentation["manifest"] || {}
|
|
720
|
+
compact_hash(
|
|
721
|
+
"manifest_digest" => presentation["manifest_digest"],
|
|
722
|
+
"submit_button_text" => manifest["submit_button_text"],
|
|
723
|
+
"combined_sentence" => manifest.dig("combined_control", "sentence"),
|
|
724
|
+
"combined_statements" => manifest.dig("combined_control", "covers"),
|
|
725
|
+
"locale" => manifest["locale"],
|
|
726
|
+
"capture_channel" => event["capture_channel"],
|
|
727
|
+
"offered_at" => manifest["issued_at"]
|
|
728
|
+
)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def fixed_provider_projection(provider)
|
|
732
|
+
provider&.except("note")
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def provider_projection(event)
|
|
736
|
+
provider = event["provider"]
|
|
737
|
+
return nil unless provider
|
|
738
|
+
|
|
739
|
+
compact_hash(
|
|
740
|
+
"name" => provider["name"],
|
|
741
|
+
"event_id" => provider["event_id"],
|
|
742
|
+
"verification" => provider["verification"]
|
|
743
|
+
)
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def receipt_chain_projection(body)
|
|
747
|
+
value = compact_hash(
|
|
748
|
+
"scope" => body.dig("integrity", "chain_scope"),
|
|
749
|
+
"sequence" => body.dig("integrity", "chain_sequence"),
|
|
750
|
+
"previous_event_digest" => body.dig("integrity", "previous_event_digest")
|
|
751
|
+
)
|
|
752
|
+
value.empty? ? nil : value
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def receipt_request_evidence_projection(body)
|
|
756
|
+
digests = body.dig("integrity", "request_evidence_category_binding_digests")
|
|
757
|
+
return nil if digests.nil? || digests.empty?
|
|
758
|
+
|
|
759
|
+
compact_hash(
|
|
760
|
+
"category_digests" => digests,
|
|
761
|
+
"algorithm" => body.dig("integrity", "request_evidence_digest_algorithm"),
|
|
762
|
+
"key_id" => body.dig("integrity", "request_evidence_key_id")
|
|
763
|
+
)
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def compact_hash(hash)
|
|
767
|
+
hash.compact
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# Each document in the receipt is checked against the bytes supplied for
|
|
771
|
+
# it, and reported `not_supplied` when none were. A bundle you did not
|
|
772
|
+
# bring is not a bundle that passed.
|
|
773
|
+
def check_documents(body, documents, checks)
|
|
774
|
+
entries = Array(body["documents"])
|
|
775
|
+
supplied = normalize_documents(documents)
|
|
776
|
+
|
|
777
|
+
if entries.empty?
|
|
778
|
+
checks << Check.new(name: "documents", passed: true,
|
|
779
|
+
detail: "This receipt cites no document versions to verify.")
|
|
780
|
+
return
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
checked = {}
|
|
784
|
+
entries.each_with_index do |entry, index|
|
|
785
|
+
check_one_document(entry, index, supplied, checks, checked)
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def check_one_document(entry, index, supplied, checks, checked)
|
|
790
|
+
unless entry.is_a?(Hash)
|
|
791
|
+
checks << Check.new(name: "document:#{index}", passed: false,
|
|
792
|
+
detail: "The document binding is not a JSON object.")
|
|
793
|
+
return
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
key = entry["key"].to_s
|
|
797
|
+
version = entry["version"].to_s
|
|
798
|
+
locale = entry["locale"].to_s
|
|
799
|
+
identity = document_identity(key, version, locale, fallback: index)
|
|
800
|
+
supplied_entry = supplied[document_identity(key, version, locale)] ||
|
|
801
|
+
supplied["#{key}@#{version}"] || supplied[key]
|
|
802
|
+
source_bytes = document_artifact_bytes(supplied, supplied_entry, key, version, locale, "source")
|
|
803
|
+
if source_bytes.nil? && !supplied_entry.is_a?(Hash) &&
|
|
804
|
+
entry["source_digest"].to_s == entry["rendered_digest"].to_s
|
|
805
|
+
source_bytes = supplied_entry
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
check_document_artifact(
|
|
809
|
+
identity: identity,
|
|
810
|
+
artifact: "source",
|
|
811
|
+
expected: entry["source_digest"],
|
|
812
|
+
bytes: source_bytes,
|
|
813
|
+
checks: checks,
|
|
814
|
+
checked: checked
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
check_document_artifact(
|
|
818
|
+
identity: identity,
|
|
819
|
+
artifact: "rendered",
|
|
820
|
+
expected: entry["rendered_digest"],
|
|
821
|
+
bytes: document_artifact_bytes(supplied, supplied_entry, key, version, locale, "rendered"),
|
|
822
|
+
checks: checks,
|
|
823
|
+
checked: checked
|
|
824
|
+
)
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def check_document_artifact(identity:, artifact:, expected:, bytes:, checks:, checked:)
|
|
828
|
+
name = "document:#{identity}:#{artifact}"
|
|
829
|
+
return if checked[name]
|
|
830
|
+
|
|
831
|
+
checked[name] = true
|
|
832
|
+
expected = expected.to_s
|
|
833
|
+
|
|
834
|
+
if expected.empty? || Digest.algorithm_of(expected).nil?
|
|
835
|
+
checks << Check.new(
|
|
836
|
+
name: name, passed: false,
|
|
837
|
+
detail: "The receipt carries no supported #{artifact}_digest for this document artifact."
|
|
838
|
+
)
|
|
839
|
+
return
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
if bytes.nil?
|
|
843
|
+
checks << Check.new(
|
|
844
|
+
name: name, passed: nil,
|
|
845
|
+
detail: "not_supplied — no #{artifact} bytes were provided, so the recorded " \
|
|
846
|
+
"digest #{expected} was not checked against anything."
|
|
847
|
+
)
|
|
848
|
+
return
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
checks << if Digest.matches?(bytes, expected)
|
|
852
|
+
Check.new(name: name, passed: true,
|
|
853
|
+
detail: "The supplied bytes hash to the recorded digest #{expected}.")
|
|
854
|
+
else
|
|
855
|
+
Check.new(
|
|
856
|
+
name: name, passed: false,
|
|
857
|
+
detail: "The supplied #{artifact} bytes do not hash to #{expected}. These are not " \
|
|
858
|
+
"the #{artifact} bytes this receipt binds."
|
|
859
|
+
)
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# A caller may be maximally explicit:
|
|
864
|
+
#
|
|
865
|
+
# { "terms@2026-08" => { source: source_bytes, rendered: html_bytes } }
|
|
866
|
+
#
|
|
867
|
+
# Flat `terms@2026-08:source` / `:rendered` keys work too. A bare String
|
|
868
|
+
# means the rendered representation the receipt records as offered. If
|
|
869
|
+
# source and rendered digests are the same, that one byte string can
|
|
870
|
+
# legitimately verify both artifacts.
|
|
871
|
+
def document_artifact_bytes(supplied, entry, key, version, locale, artifact)
|
|
872
|
+
explicit = supplied["#{document_identity(key, version, locale)}:#{artifact}"] ||
|
|
873
|
+
supplied["#{key}@#{version}:#{artifact}"] || supplied["#{key}:#{artifact}"]
|
|
874
|
+
return explicit unless explicit.nil?
|
|
875
|
+
|
|
876
|
+
if entry.is_a?(Hash)
|
|
877
|
+
return entry[artifact] || entry[artifact.to_sym] || entry["#{artifact}_bytes"] ||
|
|
878
|
+
entry[:"#{artifact}_bytes"]
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
return entry if artifact == "rendered"
|
|
882
|
+
|
|
883
|
+
nil
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def document_identity(key, version, locale, fallback: nil)
|
|
887
|
+
parts = [key, version, locale].reject(&:empty?)
|
|
888
|
+
parts.empty? ? fallback.to_s : parts.join("@")
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# Chain linkage, when a receipt carries any. A single receipt can only
|
|
892
|
+
# show that its own links are internally coherent — a scope with a
|
|
893
|
+
# sequence, a previous digest that is well-formed. Proving a chain has
|
|
894
|
+
# not been rewritten needs the neighbouring receipts and a head held
|
|
895
|
+
# somewhere the database operator does not control, and this verifier
|
|
896
|
+
# says so rather than implying it checked more than it did.
|
|
897
|
+
def check_chain_linkage(body, checks)
|
|
898
|
+
integrity = body[INTEGRITY_KEY]
|
|
899
|
+
return unless integrity.is_a?(Hash)
|
|
900
|
+
|
|
901
|
+
scope = integrity["chain_scope"]
|
|
902
|
+
sequence = integrity["chain_sequence"]
|
|
903
|
+
previous = integrity["previous_event_digest"]
|
|
904
|
+
|
|
905
|
+
if scope.nil? && sequence.nil? && previous.nil?
|
|
906
|
+
checks << Check.new(
|
|
907
|
+
name: "chain_linkage", passed: true,
|
|
908
|
+
detail: "This receipt is not chained. Chaining is optional and off by default; its " \
|
|
909
|
+
"absence is a configuration fact, not a finding."
|
|
910
|
+
)
|
|
911
|
+
return
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
problems = chain_problems(scope, sequence, previous)
|
|
915
|
+
|
|
916
|
+
checks << if problems.empty?
|
|
917
|
+
Check.new(
|
|
918
|
+
name: "chain_linkage", passed: true,
|
|
919
|
+
detail: "Chain scope #{scope.inspect} at sequence #{sequence} links to #{previous}. " \
|
|
920
|
+
"Internally consistent only: verifying that the chain itself was not " \
|
|
921
|
+
"rewritten needs the neighbouring receipts and an independently held head."
|
|
922
|
+
)
|
|
923
|
+
else
|
|
924
|
+
Check.new(name: "chain_linkage", passed: false, detail: problems.join(" "))
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def chain_problems(scope, sequence, previous)
|
|
929
|
+
problems = []
|
|
930
|
+
problems << "The receipt has chain fields but no chain_scope." if scope.nil?
|
|
931
|
+
|
|
932
|
+
if sequence.nil?
|
|
933
|
+
problems << "The receipt has a chain_scope but no chain_sequence."
|
|
934
|
+
elsif !sequence.is_a?(Integer) || sequence.negative?
|
|
935
|
+
problems << "chain_sequence #{sequence.inspect} is not a non-negative integer."
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
if previous && Digest.algorithm_of(previous).nil?
|
|
939
|
+
problems << "previous_event_digest #{previous.inspect} is not an algorithm-prefixed digest."
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Sequence one is the first event of a chain and legitimately has no
|
|
943
|
+
# predecessor; any later position must name one.
|
|
944
|
+
if previous.nil? && sequence.is_a?(Integer) && sequence > 1
|
|
945
|
+
problems << "Sequence #{sequence} is not the first event of its chain but names no " \
|
|
946
|
+
"previous_event_digest."
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
problems
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def normalize_documents(documents)
|
|
953
|
+
(documents || {}).to_h { |key, value| [key.to_s, value] }
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def deep_copy(value)
|
|
957
|
+
case value
|
|
958
|
+
when Hash then value.to_h { |key, nested| [key, deep_copy(nested)] }
|
|
959
|
+
when Array then value.map { |nested| deep_copy(nested) }
|
|
960
|
+
else value
|
|
961
|
+
end
|
|
962
|
+
end
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
class DuplicateJsonKey < StandardError; end
|
|
966
|
+
|
|
967
|
+
# JSON.parse target that refuses duplicate object keys instead of silently
|
|
968
|
+
# keeping the last value and verifying a different object than a reader saw.
|
|
969
|
+
class UniqueKeyHash < Hash
|
|
970
|
+
def []=(key, value)
|
|
971
|
+
raise DuplicateJsonKey, "The JSON object repeats key #{key.inspect}." if key?(key)
|
|
972
|
+
|
|
973
|
+
super
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
private_constant :DuplicateJsonKey, :UniqueKeyHash
|
|
977
|
+
end
|
|
978
|
+
end
|