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 +7 -0
- data/lib/unidpp/annexb.rb +296 -0
- data/lib/unidpp/canonical_json.rb +37 -0
- data/lib/unidpp/event.rb +103 -0
- data/lib/unidpp/event_log.rb +225 -0
- data/lib/unidpp/gs1.rb +105 -0
- data/lib/unidpp/identifier.rb +240 -0
- data/lib/unidpp/link.rb +197 -0
- data/lib/unidpp/manifest.rb +328 -0
- data/lib/unidpp/quantity.rb +150 -0
- data/lib/unidpp/registry/applicability.rb +66 -0
- data/lib/unidpp/registry/client.rb +141 -0
- data/lib/unidpp/registry/file_store.rb +121 -0
- data/lib/unidpp/registry/item.rb +132 -0
- data/lib/unidpp/registry/item_version.rb +66 -0
- data/lib/unidpp/registry/register.rb +36 -0
- data/lib/unidpp/registry/store.rb +26 -0
- data/lib/unidpp/registry.rb +21 -0
- data/lib/unidpp/verdict.rb +72 -0
- data/lib/unidpp/version.rb +5 -0
- data/lib/unidpp.rb +31 -0
- metadata +108 -0
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
|
data/lib/unidpp/event.rb
ADDED
|
@@ -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
|