unidpp 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 36b87e3059f33a1fcf05f11132a11b1a41e93712fd0646d676734368a51b1776
4
+ data.tar.gz: 358c874dd088c697f3dc34a794c9ddf45b3d91221c91f6a170c2bfe254665bbf
5
+ SHA512:
6
+ metadata.gz: 56bb1ff2a61e4049c007a76e8ba15fc31f6f549c11011e208adcbfea8801c86c2ac9d8963e3b19378e074d62cd0cf07d5078a08b5a67bdd29171b58d50234ff2
7
+ data.tar.gz: 9e9cd4772a9cdf050ee4944059eb6933784a1ba97971c9d07cfe5e35261a3d813933eea8d77c967fb74738897b085709c54bfbd843205516271d4b2ab4577c50
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Unidpp
6
+ # The Annex B binary canonical encoding (TODO 237), written from the
7
+ # specification: the framing rule (each field a little-endian u32
8
+ # length prefix and its bytes, in the declared field order), the
9
+ # domain tags, and the per-object derivations. The vendored golden
10
+ # vectors carry the reference `canonical_hex` pins; the suite
11
+ # asserts byte identity — this implementation agrees with the
12
+ # reference or the suite fails. That is the cross-implementation
13
+ # contract.
14
+ module AnnexB
15
+ module_function
16
+
17
+ SEGMENT_COMMITMENT = "UNIDPP-GRID/SEGMENT-COMMITMENT"
18
+ SPINE_LEAF = "UNIDPP-GRID/SPINE-LEAF"
19
+ SPINE_NODE = "UNIDPP-GRID/SPINE-NODE"
20
+ SPINE_DIGEST = "UNIDPP-GRID/SPINE-DIGEST"
21
+
22
+ REVEAL_TOKENS = {
23
+ "Open" => "open",
24
+ "PairingGated" => "pairing-gated",
25
+ "OriginSealed" => "origin-sealed",
26
+ "Escrowed" => "escrowed"
27
+ }.freeze
28
+
29
+ CLAIM_TOKENS = {
30
+ "conformity" => "conformity",
31
+ "commitment-hash" => "commitment-hash",
32
+ "freshness" => "freshness"
33
+ }.freeze
34
+
35
+ EVIDENCE_TOKENS = {
36
+ "verified-direct" => "verified-direct",
37
+ "attested-by-authority" => "attested-by-authority",
38
+ "explicitly-unavailable" => "explicitly-unavailable"
39
+ }.freeze
40
+
41
+ LEVEL_ORDINALS = {
42
+ "l0" => 0, "l1" => 1, "l2" => 2, "l3" => 3, "l4" => 4, "l5" => 5
43
+ }.freeze
44
+
45
+ def sha256(data)
46
+ Digest::SHA256.digest(data)
47
+ end
48
+
49
+ def le_u32(value)
50
+ [value].pack("V")
51
+ end
52
+
53
+ def le_u64(value)
54
+ [value].pack("Q<")
55
+ end
56
+
57
+ def part(bytes)
58
+ # UTF-8 strings keep their bytes (relabelled, never transcoded):
59
+ # the canonical bytes ARE the UTF-8 encoding, and the framing
60
+ # layer works in binary throughout.
61
+ binary = if bytes.is_a?(String)
62
+ bytes.dup.force_encoding(Encoding::BINARY)
63
+ else
64
+ bytes.pack("C*")
65
+ end
66
+ le_u32(binary.bytesize) + binary
67
+ end
68
+
69
+ # The canonical form: fields in declared order, each
70
+ # length-prefixed.
71
+ def canonical_fields(parts)
72
+ parts.map { |p| part(p) }.join
73
+ end
74
+
75
+ # The one framing rule: tag || 0x00 || payload.
76
+ def domain_frame(tag, payload)
77
+ tag + "\x00".b + payload
78
+ end
79
+
80
+ # Hash in a domain: sha256 over the domain-framed canonical parts.
81
+ def hash_in(domain, parts)
82
+ sha256(domain_frame(domain, canonical_fields(parts)))
83
+ end
84
+
85
+ # Wire forms carry hashes either hex-encoded or as byte arrays.
86
+ def as_bytes(value)
87
+ value.is_a?(String) ? [value].pack("H*") : value.pack("C*")
88
+ end
89
+
90
+ # A sorted, deduplicated list joined with the unit separator
91
+ # (U+001F).
92
+ def canon_list(values)
93
+ values.uniq.sort.join("\x1f")
94
+ end
95
+
96
+ def policy_canonical(policy)
97
+ parts = [
98
+ policy["policy_id"],
99
+ le_u64(policy["version"]),
100
+ policy["authority"],
101
+ REVEAL_TOKENS.fetch(policy["reveal"], policy["reveal"]),
102
+ policy["valid_from"],
103
+ canon_list(policy["readers"]),
104
+ canon_list(policy["verifiers"]),
105
+ canon_list(policy["writers"]),
106
+ canon_list(policy["suites"])
107
+ ]
108
+ parts << policy["valid_to"] unless policy["valid_to"].nil?
109
+ parts << le_u64(policy["superseded_by"]) unless policy["superseded_by"].nil?
110
+ canonical_fields(parts)
111
+ end
112
+
113
+ def h_pair(left, right)
114
+ a, b = [left, right].minmax
115
+ hash_in(SPINE_NODE, [a, b])
116
+ end
117
+
118
+ def commit_state(state)
119
+ hash_in(SEGMENT_COMMITMENT, [state])
120
+ end
121
+
122
+ def spine_root(commitments)
123
+ level = commitments.keys.sort.map do |id|
124
+ hash_in(SPINE_LEAF, [id, as_bytes(commitments[id])])
125
+ end
126
+ return "\x00".b * 32 if level.empty?
127
+
128
+ while level.size > 1
129
+ level = level.each_slice(2).map do |pair|
130
+ pair.size == 2 ? h_pair(pair[0], pair[1]) : h_pair(pair[0], pair[0])
131
+ end
132
+ end
133
+ level.first
134
+ end
135
+
136
+ def spine_digest(version, commitments)
137
+ buf = le_u64(version) + spine_root(commitments)
138
+ commitments.keys.sort.each do |id|
139
+ buf += le_u32(id.bytesize) + id + as_bytes(commitments[id])
140
+ end
141
+ hash_in(SPINE_DIGEST, [buf])
142
+ end
143
+
144
+ def request_canonical(request)
145
+ canonical_fields(
146
+ [
147
+ request["verifier"],
148
+ request["subject"],
149
+ request["profile"],
150
+ request["segment"],
151
+ request["at"]
152
+ ]
153
+ )
154
+ end
155
+
156
+ def response_canonical(response)
157
+ outcome = response["outcome"]
158
+ payload = outcome.reject { |k, _| k == "outcome" }
159
+ canonical_fields(
160
+ [
161
+ as_bytes(response["request_digest"]),
162
+ outcome["outcome"],
163
+ *payload.values,
164
+ response["governing_policy"],
165
+ le_u64(response["governing_policy_version"]),
166
+ response["custodian"]
167
+ ]
168
+ )
169
+ end
170
+
171
+ def route_canonical(route)
172
+ parts = [le_u64(route["steps"].size)]
173
+ route["steps"].each do |step|
174
+ kind = step["step"]
175
+ parts << kind
176
+ case kind
177
+ when "resolve" then parts << step["subject"]
178
+ when "transport" then parts << step["mode"] << step["counterpart"]
179
+ when "document" then parts << step["kind"] << as_bytes(step["digest"])
180
+ when "substitution" then parts << step["data_class"] << step["service"]
181
+ when "classify"
182
+ e = step["entry"]
183
+ parts << e["class"] << e["element_set"] <<
184
+ EVIDENCE_TOKENS.fetch(e["evidence"], e["evidence"]) <<
185
+ e["governing_policy"] << le_u64(e["governing_policy_version"]) <<
186
+ e["reading"] << e["as_of"]
187
+ when "gap" then parts << step["data_class"] << step["reason"]
188
+ end
189
+ end
190
+ canonical_fields(parts)
191
+ end
192
+
193
+ def route_digest(route)
194
+ sha256(route_canonical(route))
195
+ end
196
+
197
+ def report_canonical(report)
198
+ parts = [
199
+ report["subject"],
200
+ report["profile"],
201
+ report["verified_at"],
202
+ le_u64(report["route"] ? 1 : 0)
203
+ ]
204
+ parts << route_digest(report["route"]) if report["route"]
205
+ parts << le_u64(report["entries"].size)
206
+ report["entries"].each do |e|
207
+ parts << e["class"] << e["element_set"] <<
208
+ EVIDENCE_TOKENS.fetch(e["evidence"], e["evidence"]) <<
209
+ e["governing_policy"] << le_u64(e["governing_policy_version"]) <<
210
+ e["reading"] << e["as_of"]
211
+ end
212
+ canonical_fields(parts)
213
+ end
214
+
215
+ def statement_canonical(statement)
216
+ canonical_fields(
217
+ [
218
+ statement["segment"],
219
+ as_bytes(statement["state_commitment"]),
220
+ CLAIM_TOKENS.fetch(statement["claim"], statement["claim"]),
221
+ statement["value"],
222
+ statement["as_of"],
223
+ statement["governing_policy"],
224
+ le_u64(statement["governing_policy_version"]),
225
+ statement["subject"]
226
+ ]
227
+ )
228
+ end
229
+
230
+ def descriptor_token(descriptor)
231
+ [descriptor["temporal"], descriptor["content"], descriptor["derivation"],
232
+ descriptor["exchange"], descriptor["granularity"]].join("·")
233
+ end
234
+
235
+ def frozen_view_canonical(view)
236
+ parts = [
237
+ view["subject"],
238
+ descriptor_token(view["descriptor"]),
239
+ view["lens"]["profile"]
240
+ ]
241
+ view["lens"]["transforms"].each do |step|
242
+ parts << step["reference"] << le_u64(step["version"])
243
+ end
244
+ view["lens"]["input_segments"].each do |name, segment|
245
+ parts << name << segment
246
+ end
247
+ parts << as_bytes(view["payload"])
248
+ view["inputs"].each do |input|
249
+ parts << input["name"] << commit_state(as_bytes(input["bytes"]))
250
+ end
251
+ view["instructions"].each { |instruction| parts << instruction }
252
+ parts << view["notarized_at"]
253
+ parts << spine_digest(view["bundle"]["spine"]["spine"]["version"],
254
+ view["bundle"]["spine"]["spine"]["commitments"])
255
+ canonical_fields(parts)
256
+ end
257
+
258
+ def declaration_canonical(declaration)
259
+ postures = declaration["postures"].sort_by { |p| p["data_class"] }
260
+ parts = [
261
+ declaration["declarer"],
262
+ declaration["counterpart"],
263
+ le_u64(declaration["version"])
264
+ ]
265
+ postures.each do |posture|
266
+ parts << posture["data_class"]
267
+ parts << [LEVEL_ORDINALS.fetch(posture["level"], 255)].pack("C")
268
+ parts << posture["recognition"]
269
+ posture["transports"].sort.each { |transport| parts << transport }
270
+ parts << (posture["escalation"] || "")
271
+ parts << (posture["reciprocity"] || "")
272
+ end
273
+ parts << declaration["valid_from"]
274
+ parts << (declaration["valid_to"] || "")
275
+ canonical_fields(parts)
276
+ end
277
+
278
+ def mapping_item_canonical(item)
279
+ parts = [item["source"], item["target"], le_u64(item["version"])]
280
+ kind = item["kind"]
281
+ case kind["tier"]
282
+ when "deterministic"
283
+ parts << "tier-1" << kind["transform"]
284
+ when "correspondence"
285
+ parts << "tier-2"
286
+ kind["scope"].sort.each { |scope| parts << scope }
287
+ parts << kind["residual"] << kind["attester"]
288
+ when "no-mapping"
289
+ parts << "tier-3" << kind["note"]
290
+ else
291
+ raise ArgumentError, "unknown mapping tier `#{kind['tier']}'"
292
+ end
293
+ canonical_fields(parts)
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Unidpp
6
+ # Deterministic canonical JSON used for commitments (event-log chaining,
7
+ # blind-edge parent commitments).
8
+ #
9
+ # The source document is always the *framework-generated* serialization
10
+ # of a Lutaml::Model object (to_json); this module only imposes a
11
+ # canonical key order on top of that output so that identical logical
12
+ # documents always hash identically, regardless of hash insertion order.
13
+ # It is a canonicalizer, not a serializer: no model mappings live here.
14
+ module CanonicalJson
15
+ module_function
16
+
17
+ # @param serializable [Lutaml::Model::Serializable]
18
+ # @return [String] canonical JSON with all object keys sorted
19
+ # (recursively).
20
+ def of(serializable)
21
+ sorted(JSON.parse(serializable.to_json)).to_json
22
+ end
23
+
24
+ # @param obj [Object] parsed JSON structure
25
+ # @return [Object] same structure with every Hash's keys sorted
26
+ def sorted(obj)
27
+ case obj
28
+ when Hash
29
+ obj.keys.sort.to_h { |k| [k, sorted(obj[k])] }
30
+ when Array
31
+ obj.map { |v| sorted(v) }
32
+ else
33
+ obj
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Unidpp
6
+ # Typed lifecycle event (I4: append-only event sourcing; the event log
7
+ # is the authoritative record). The post-first-sale taxonomy E1–E15
8
+ # with the roles authorized to append each kind — nothing is ever
9
+ # edited in place; every change is an appended event.
10
+ class Event < Lutaml::Model::Serializable
11
+ # event type -> roles that may append it (the appender column of the
12
+ # taxonomy; "any" means any custodian-role actor per profile rules).
13
+ TAXONOMY = {
14
+ "custody.transfer" => %w[custodian counterparty],
15
+ "part.replace" => %w[repairer],
16
+ "repair.perform" => %w[repairer],
17
+ "product.modify" => %w[modifier],
18
+ "software.update" => %w[eo service],
19
+ "feature.unlock" => %w[eo service],
20
+ "upgrade.install" => %w[installer],
21
+ "refurbish.perform" => %w[refurbisher],
22
+ "consumable.replace" => %w[custodian repairer eo service installer],
23
+ "recall.campaign" => %w[regulator eo],
24
+ "correction.record" => %w[eo manufacturer certifier],
25
+ "status.change" => %w[regulator eo],
26
+ "flag.security" => %w[authority registrar],
27
+ "decompose.perform" => %w[recycler transformer],
28
+ "inspection.stamp" => %w[verifier certifier],
29
+ "milestone.record" => %w[device custodian],
30
+ }.freeze
31
+
32
+ EVENT_TYPES = TAXONOMY.keys.freeze
33
+
34
+ ACTOR_ROLES = TAXONOMY.values.flatten.uniq.freeze
35
+
36
+ # Graded trust markers (I9 / source invariant 8: trust is graded, not
37
+ # mandated — every event carries one).
38
+ TRUST_MARKERS = %w[
39
+ unsigned self_declared third_party_attested multi_signed log_anchored
40
+ ].freeze
41
+
42
+ class UnauthorizedAppenderError < Unidpp::Error; end
43
+
44
+ # event_id has deliberately NO default: the log assigns it exactly
45
+ # once at append, so the committed canonical form is stable whether
46
+ # the event was hand-built or round-tripped.
47
+ attribute :event_id, :string
48
+ attribute :event_type, :string, values: EVENT_TYPES
49
+ attribute :occurred_at, "Lutaml::Model::Type::DateTime"
50
+ attribute :actor, :string # named actor
51
+ attribute :actor_role, :string, values: ACTOR_ROLES
52
+ # Canonical identity of the subject whose log this event appends to.
53
+ attribute :subject, :string
54
+ # Free event payload (string-keyed, scalar values); canonicalized by
55
+ # the event log for commitments.
56
+ attribute :payload, :hash, default: -> { {} }
57
+ attribute :trust_marker, :string, values: TRUST_MARKERS,
58
+ default: -> { "unsigned" }
59
+
60
+ # Defaulted attributes render always (render_default: true) so that
61
+ # the canonical JSON hashed into commitments does not depend on
62
+ # whether the value was explicitly assigned or materialized.
63
+ json do
64
+ map "event_id", to: :event_id
65
+ map "event_type", to: :event_type
66
+ map "occurred_at", to: :occurred_at
67
+ map "actor", to: :actor, render_nil: false
68
+ map "actor_role", to: :actor_role
69
+ map "subject", to: :subject
70
+ map "payload", to: :payload, render_default: true
71
+ map "trust_marker", to: :trust_marker, render_default: true
72
+ end
73
+
74
+ yaml do
75
+ map "event_id", to: :event_id
76
+ map "event_type", to: :event_type
77
+ map "occurred_at", to: :occurred_at
78
+ map "actor", to: :actor, render_nil: false
79
+ map "actor_role", to: :actor_role
80
+ map "subject", to: :subject
81
+ map "payload", to: :payload, render_default: true
82
+ map "trust_marker", to: :trust_marker, render_default: true
83
+ end
84
+
85
+ # Appender authorization: the actor role must be among the roles the
86
+ # taxonomy allows for this event kind (I4 discipline — custody
87
+ # transfer is appended by the custodian, a recall by the regulator,
88
+ # and so on).
89
+ #
90
+ # @return [Boolean]
91
+ def authorized_appender?
92
+ TAXONOMY.fetch(event_type, []).include?(actor_role)
93
+ end
94
+
95
+ # @raise [UnauthorizedAppenderError]
96
+ def authorize_appender!
97
+ unless authorized_appender?
98
+ raise UnauthorizedAppenderError,
99
+ "#{actor_role} may not append #{event_type}"
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+
6
+ module Unidpp
7
+ # Append-only, hash-chained event log with salted commitments (I4, S6).
8
+ #
9
+ # Chain rule: each entry's commitment is
10
+ #
11
+ # SHA-256( canonical_json(event) || salt || previous_commitment )
12
+ #
13
+ # where canonical_json is the framework (lutaml-model) serialization of
14
+ # the event under a deterministic key order. The salt disciplines the
15
+ # log↔transparency-log seam: logs anchor commitments, never facts — a
16
+ # reveal (event + salt) must remain inclusion-consistent with every
17
+ # past commitment ("commit now, reveal later, never contradict").
18
+ class EventLog
19
+ class IntegrityError < Unidpp::Error; end
20
+
21
+ # The commitment envelope of one event. (De)serialization is generated
22
+ # by lutaml-model — no hand-rolled to_h / from_h.
23
+ class Entry < Lutaml::Model::Serializable
24
+ attribute :event, Event
25
+ attribute :salt, :string # hex — the disclosure key
26
+ attribute :previous, :string # hex commitment of prior entry
27
+ attribute :commitment, :string # hex commitment of this entry
28
+
29
+ json do
30
+ map "event", to: :event
31
+ map "salt", to: :salt
32
+ map "previous", to: :previous
33
+ map "commitment", to: :commitment
34
+ end
35
+
36
+ yaml do
37
+ map "event", to: :event
38
+ map "salt", to: :salt
39
+ map "previous", to: :previous
40
+ map "commitment", to: :commitment
41
+ end
42
+ end
43
+
44
+ # Serialized form of a whole log. (De)serialization is generated by
45
+ # lutaml-model — no hand-rolled to_h / from_h.
46
+ class Document < Lutaml::Model::Serializable
47
+ attribute :subject, :string
48
+ attribute :algorithm, :string, default: -> { "sha256" }
49
+ attribute :entries, Entry, collection: true, default: -> { [] }
50
+
51
+ json do
52
+ map "subject", to: :subject
53
+ map "algorithm", to: :algorithm
54
+ map "entries", to: :entries
55
+ end
56
+
57
+ yaml do
58
+ map "subject", to: :subject
59
+ map "algorithm", to: :algorithm
60
+ map "entries", to: :entries
61
+ end
62
+ end
63
+
64
+ # Commitment of the empty log (genesis value of the chain).
65
+ GENESIS = "0" * 64
66
+ ALGORITHM = "sha256"
67
+
68
+ attr_reader :document
69
+
70
+ def initialize(subject:, entries: [])
71
+ @document = Document.new(subject: subject, algorithm: ALGORITHM,
72
+ entries: entries)
73
+ @document.entries.freeze
74
+ end
75
+
76
+ # -- append-only writer ---------------------------------------------------
77
+
78
+ # Appends +event+ with a fresh salt, chaining onto the current head.
79
+ # The event's id is assigned here exactly once (if the caller did not
80
+ # set one) so the committed canonical form is stable; existing
81
+ # entries are never touched.
82
+ #
83
+ # @param event [Event]
84
+ # @param salt [String] disclosure key (generated when omitted)
85
+ # @return [Entry]
86
+ def append(event, salt: SecureRandom.hex(16))
87
+ event.event_id ||= SecureRandom.uuid
88
+ entry = Entry.new(event: event, salt: salt, previous: head,
89
+ commitment: nil)
90
+ entry.commitment = commitment_for(entry.event, entry.salt,
91
+ entry.previous)
92
+ new_entries = @document.entries + [entry]
93
+ @document = Document.new(subject: @document.subject,
94
+ algorithm: @document.algorithm,
95
+ entries: new_entries)
96
+ @document.entries.freeze
97
+ entry
98
+ end
99
+
100
+ # -- queries ---------------------------------------------------------------
101
+
102
+ # @return [Array<Entry>]
103
+ def entries
104
+ @document.entries
105
+ end
106
+
107
+ def events
108
+ entries.map(&:event)
109
+ end
110
+
111
+ def empty?
112
+ entries.empty?
113
+ end
114
+
115
+ # Head commitment: the value a transparency log anchors.
116
+ #
117
+ # @return [String] hex
118
+ def head
119
+ entries.last&.commitment || GENESIS
120
+ end
121
+ alias commitment head
122
+
123
+ def subject
124
+ @document.subject
125
+ end
126
+
127
+ # Notarized as-of state: the contiguous sub-log of events that had
128
+ # occurred at +t+. The sub-chain re-verifies because chaining is
129
+ # prefix-consistent.
130
+ #
131
+ # @param t [Time, DateTime]
132
+ # @return [EventLog]
133
+ def as_of(t)
134
+ t = t.is_a?(DateTime) ? t : DateTime.parse(t.to_s)
135
+ EventLog.new(subject: subject,
136
+ entries: entries.select do |e|
137
+ !e.event.occurred_at.nil? && e.event.occurred_at <= t
138
+ end)
139
+ end
140
+
141
+ # The reveal pair for an entry — the material a counterparty needs to
142
+ # check inclusion-consistency of a past commitment.
143
+ #
144
+ # @param event_id [String]
145
+ # @return [Entry, nil]
146
+ def reveal(event_id)
147
+ entries.find { |e| e.event.event_id == event_id }
148
+ end
149
+
150
+ # -- integrity ---------------------------------------------------------------
151
+
152
+ # @return [Array<String>] human-readable integrity problems, [] when
153
+ # the chain is sound
154
+ def integrity_errors
155
+ errors = []
156
+ previous = GENESIS
157
+ entries.each_with_index do |entry, index|
158
+ if entry.previous != previous
159
+ errors << "entry #{index} (#{entry.event.event_type}): " \
160
+ "previous #{entry.previous.inspect} != #{previous.inspect}"
161
+ end
162
+ expected = commitment_for(entry.event, entry.salt, entry.previous)
163
+ if entry.commitment != expected
164
+ errors << "entry #{index} (#{entry.event.event_type}): " \
165
+ "commitment does not cover the stored event+salt"
166
+ end
167
+ previous = entry.commitment
168
+ end
169
+ errors
170
+ end
171
+
172
+ # @return [Boolean]
173
+ def valid?
174
+ integrity_errors.empty?
175
+ end
176
+
177
+ # @raise [IntegrityError] on the first problem found
178
+ def verify!
179
+ errors = integrity_errors
180
+ return self if errors.empty?
181
+
182
+ raise IntegrityError, errors.first
183
+ end
184
+
185
+ # Inclusion-consistency of a reveal: recomputing the commitment from
186
+ # the revealed event and salt must reproduce the anchored commitment.
187
+ #
188
+ # @param entry [Entry]
189
+ # @return [Boolean]
190
+ def consistent_reveal?(entry)
191
+ entry.commitment == commitment_for(entry.event, entry.salt,
192
+ entry.previous)
193
+ end
194
+
195
+ # -- framework serialization -------------------------------------------------
196
+
197
+ def self.from_yaml(yaml)
198
+ document = Document.from_yaml(yaml)
199
+ new(subject: document.subject, entries: document.entries)
200
+ end
201
+
202
+ def self.from_json(json)
203
+ document = Document.from_json(json)
204
+ new(subject: document.subject, entries: document.entries)
205
+ end
206
+
207
+ def to_yaml
208
+ @document.to_yaml
209
+ end
210
+
211
+ def to_json(*_args)
212
+ @document.to_json
213
+ end
214
+
215
+ private
216
+
217
+ # SHA-256 over the canonical JSON of the event, the salt and the
218
+ # previous commitment.
219
+ def commitment_for(event, salt, previous)
220
+ Digest::SHA256.hexdigest(
221
+ [CanonicalJson.of(event), salt, previous].join("\n")
222
+ )
223
+ end
224
+ end
225
+ end