kairos-chain 3.43.0 → 3.45.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/CHANGELOG.md +29 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/synoptis/bin/khab_verify.rb +172 -0
- data/templates/skillsets/synoptis/bin/map_verify.rb +250 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/anchoring_rule.rb +184 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/attestation_types.rb +107 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/chain_credential.rb +187 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/containment.rb +17 -1
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/khab-1.md +112 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/map-1.md +198 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/cumulative_commitment.rb +229 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/deposit_board.rb +4 -2
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/entry.rb +28 -1
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/head_binding.rb +294 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/log.rb +15 -3
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/public_verifier.rb +5 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/succession.rb +222 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/write_path.rb +5 -2
- data/templates/skillsets/synoptis/test/test_head_anchor.rb +431 -0
- data/templates/skillsets/synoptis/test/test_map_anchoring.rb +359 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea7401710d6467287e35146c84d731c08733f184fac36be13e76de11d7af5433
|
|
4
|
+
data.tar.gz: a823ba8bf1bb1157394ff04466cdcf96c0347eee50796816fe8c2fd1d188792c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a11ba74c10eb1c56ef15b57992755083b6d36bd4fef1ea73bf1cae64afc1f07ad26f8774ee703c3c98eefa25c8a422441c5c5af032b9ae523d7518c7dc50193d
|
|
7
|
+
data.tar.gz: 26f95dd3245e9e39311349ef540954d200985a59bf9da38b3b08256d11ec50a445d60cc3f0c41538b6a0c0bfb2eb591ce742dc4523950d9ffe8f06471d22e50a
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [3.45.0] - 2026-07-21
|
|
8
|
+
|
|
9
|
+
### Synoptis — mutual anchoring protocol MAP-1..4 (map-1, AUD-L2)
|
|
10
|
+
|
|
11
|
+
Implements AUD-L2 design v0.5 (FROZEN) slice 1 in the synoptis SkillSet:
|
|
12
|
+
|
|
13
|
+
- `conventions/map-1.md`: content-addressed mutual-anchoring convention
|
|
14
|
+
(chain identity credential, typed attestation vocabulary, succession
|
|
15
|
+
records, declared anchoring rule, changeover event). Builds on khab-1
|
|
16
|
+
without modifying it.
|
|
17
|
+
- `chain_credential.rb`: Ed25519 self-authenticating chain identity
|
|
18
|
+
credential — verification needs no registry and no network.
|
|
19
|
+
- `succession.rb`: succession governance (earliest non-retracted designation
|
|
20
|
+
governs; at-or-before changeover boundary; issuer-only retraction;
|
|
21
|
+
retract-and-redesignate trails surfaced in the contested register).
|
|
22
|
+
- `anchoring_rule.rb`: result-free declared anchoring rule (closed schema)
|
|
23
|
+
with a coverage checker that reports and never enforces.
|
|
24
|
+
- `attestation_types.rb`: MAP-4 vocabulary intake validation and anchor-log
|
|
25
|
+
retraction coherence (issuer = depositor at map-1, disclosed deferral).
|
|
26
|
+
- `entry.rb` / `log.rb` / `write_path.rb`: additive `attestation_type` on
|
|
27
|
+
newly appended entries only (AHM-4 byte-invariance preserved).
|
|
28
|
+
- `bin/map_verify.rb`: standalone offline verifier (credential, attestation,
|
|
29
|
+
succession, coverage, pair, retraction subcommands).
|
|
30
|
+
- `test/test_map_anchoring.rb`: 75 design-constraint tests.
|
|
31
|
+
|
|
32
|
+
Design review converged R5 5/6 APPROVE; implementation review converged
|
|
33
|
+
R4 5/5 APPROVE with zero residual P0/P1. The inaugural mutual anchor with a
|
|
34
|
+
second, independently operated HestiaChain instance is deliberately pending.
|
|
35
|
+
|
|
7
36
|
## [3.42.2] - 2026-07-14
|
|
8
37
|
|
|
9
38
|
### Knowledge — loop_engineering_patterns §B: Model/Effort budget allocation
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# khab_verify — offline auditor verifier for khab-1 head bindings (MPR-4).
|
|
5
|
+
#
|
|
6
|
+
# Verifies inclusion and consistency proofs with EXACTLY the disclosed trust
|
|
7
|
+
# base: the proof artifact, the published head binding(s), and the shipped
|
|
8
|
+
# khab-1 convention definition. No internal-chain access, no operator
|
|
9
|
+
# cooperation, no network. (The authentic anchor-log view — that the binding
|
|
10
|
+
# really is committed at its claimed position — is checked against the log's
|
|
11
|
+
# public verification surface, outside this script.)
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# khab_verify.rb inclusion <proof.json> <binding.json>
|
|
15
|
+
# khab_verify.rb consistency <proof.json> <earlier_binding.json> <later_binding.json>
|
|
16
|
+
#
|
|
17
|
+
# A binding file may be the bare head_binding object, or any JSON object
|
|
18
|
+
# carrying it under "head_binding" (e.g. a public-verifier record or an anchor
|
|
19
|
+
# entry body).
|
|
20
|
+
#
|
|
21
|
+
# Exit status: 0 = VERIFIED, 1 = REJECTED, 2 = usage / unresolvable input.
|
|
22
|
+
|
|
23
|
+
require 'json'
|
|
24
|
+
require 'digest'
|
|
25
|
+
require_relative '../lib/synoptis/anchoring/cumulative_commitment'
|
|
26
|
+
|
|
27
|
+
CONVENTION_PATH = File.expand_path('../lib/synoptis/anchoring/conventions/khab-1.md', __dir__)
|
|
28
|
+
CC = Synoptis::Anchoring::CumulativeCommitment
|
|
29
|
+
|
|
30
|
+
def die(msg)
|
|
31
|
+
warn "khab_verify: #{msg}"
|
|
32
|
+
exit 2
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reject(msg)
|
|
36
|
+
puts "REJECTED: #{msg}"
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def load_json(path)
|
|
41
|
+
parsed = JSON.parse(File.read(path))
|
|
42
|
+
die "#{path}: expected a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
|
|
43
|
+
parsed
|
|
44
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
45
|
+
die "cannot read #{path}: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_binding(obj, path)
|
|
49
|
+
b = obj.is_a?(Hash) && obj.key?('head_binding') ? obj['head_binding'] : obj
|
|
50
|
+
die "#{path} carries no head binding" unless b.is_a?(Hash) && b['convention']
|
|
51
|
+
b
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Structural well-formedness of a binding's verifiable fields, checked BEFORE
|
|
55
|
+
# any verdict logic. The strongest claims this verifier can emit (VERIFIED,
|
|
56
|
+
# DIVERGENCE) must be unreachable from malformed input: a missing or
|
|
57
|
+
# non-canonical root/extent is unresolvable input (exit 2), never evidence.
|
|
58
|
+
def require_wellformed!(binding, path)
|
|
59
|
+
unless binding['cumulative_root'].is_a?(String) && binding['cumulative_root'].match?(/\A[a-f0-9]{64}\z/)
|
|
60
|
+
die "#{path}: cumulative_root missing or not 64-char lowercase hex (khab-1 §4)"
|
|
61
|
+
end
|
|
62
|
+
unless binding['tree_size'].is_a?(Integer) && binding['tree_size'].positive? && binding['tree_size'] < 2**53
|
|
63
|
+
die "#{path}: tree_size missing or not a positive JSON-safe integer (khab-1 §4)"
|
|
64
|
+
end
|
|
65
|
+
return if binding['chain_identity'].is_a?(String) &&
|
|
66
|
+
binding['chain_identity'].match?(/\Ablock1-sha256:[a-f0-9]{64}\z/)
|
|
67
|
+
|
|
68
|
+
die "#{path}: chain_identity missing or not in khab-1 §5 canonical form (block1-sha256:<64-hex>)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# MPR-3: the convention identifier must resolve to a definition whose own
|
|
72
|
+
# integrity the auditor can verify. A digest mismatch means the binding was
|
|
73
|
+
# computed under a definition this verifier does not hold — unresolvable, so
|
|
74
|
+
# verification refuses rather than guessing.
|
|
75
|
+
def check_convention!(binding)
|
|
76
|
+
reject "unknown convention #{binding['convention'].inspect} (this verifier implements khab-1)" unless
|
|
77
|
+
binding['convention'] == 'khab-1'
|
|
78
|
+
local = begin
|
|
79
|
+
Digest::SHA256.hexdigest(File.binread(CONVENTION_PATH))
|
|
80
|
+
rescue SystemCallError => e
|
|
81
|
+
die "khab-1 convention definition unreadable at #{CONVENTION_PATH}: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
return if binding['convention_sha256'] == local
|
|
84
|
+
|
|
85
|
+
reject "convention_sha256 #{binding['convention_sha256']} does not match the held khab-1 " \
|
|
86
|
+
"definition (#{local}); convention unresolvable"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def require_match(proof, binding, proof_key, binding_key, label)
|
|
90
|
+
return if proof[proof_key] == binding[binding_key]
|
|
91
|
+
|
|
92
|
+
reject "proof #{label} #{proof[proof_key].inspect} does not match binding #{binding[binding_key].inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
mode = ARGV.shift
|
|
96
|
+
|
|
97
|
+
case mode
|
|
98
|
+
when 'inclusion'
|
|
99
|
+
die 'usage: khab_verify.rb inclusion <proof.json> <binding.json>' unless ARGV.size == 2
|
|
100
|
+
proof = load_json(ARGV[0])
|
|
101
|
+
binding = extract_binding(load_json(ARGV[1]), ARGV[1])
|
|
102
|
+
check_convention!(binding)
|
|
103
|
+
require_wellformed!(binding, ARGV[1])
|
|
104
|
+
reject "proof format #{proof['format'].inspect} is not khab-1/inclusion" unless proof['format'] == 'khab-1/inclusion'
|
|
105
|
+
require_match(proof, binding, 'chain_identity', 'chain_identity', 'chain_identity')
|
|
106
|
+
require_match(proof, binding, 'tree_size', 'tree_size', 'tree_size')
|
|
107
|
+
require_match(proof, binding, 'cumulative_root', 'cumulative_root', 'cumulative_root')
|
|
108
|
+
|
|
109
|
+
ok = CC.verify_inclusion(
|
|
110
|
+
record_commitment: proof['record_commitment'],
|
|
111
|
+
index: proof['index'],
|
|
112
|
+
tree_size: binding['tree_size'],
|
|
113
|
+
path: proof['path'],
|
|
114
|
+
root: binding['cumulative_root']
|
|
115
|
+
)
|
|
116
|
+
reject 'inclusion proof does not recompute the committed cumulative root' unless ok
|
|
117
|
+
|
|
118
|
+
puts 'VERIFIED: record commitment ' \
|
|
119
|
+
"#{proof['record_commitment']} is member ##{proof['index']} of the anchored state " \
|
|
120
|
+
"(tree_size #{binding['tree_size']}, chain #{binding['chain_identity']})."
|
|
121
|
+
puts 'Scope (MPR-5/6): membership, integrity at anchor time, position, order — nothing about ' \
|
|
122
|
+
'content quality or completeness of anchoring; commitment, not content.'
|
|
123
|
+
|
|
124
|
+
when 'consistency'
|
|
125
|
+
die 'usage: khab_verify.rb consistency <proof.json> <earlier.json> <later.json>' unless ARGV.size == 3
|
|
126
|
+
proof = load_json(ARGV[0])
|
|
127
|
+
earlier = extract_binding(load_json(ARGV[1]), ARGV[1])
|
|
128
|
+
later = extract_binding(load_json(ARGV[2]), ARGV[2])
|
|
129
|
+
check_convention!(earlier)
|
|
130
|
+
check_convention!(later)
|
|
131
|
+
require_wellformed!(earlier, ARGV[1])
|
|
132
|
+
require_wellformed!(later, ARGV[2])
|
|
133
|
+
reject "proof format #{proof['format'].inspect} is not khab-1/consistency" unless proof['format'] == 'khab-1/consistency'
|
|
134
|
+
# MPR-9: extension-relatedness is defined only over the same committed chain
|
|
135
|
+
# identity; different identities terminate the claim rather than failing it.
|
|
136
|
+
reject "bindings commit different chain identities (#{earlier['chain_identity']} vs " \
|
|
137
|
+
"#{later['chain_identity']}); extension claim terminated across an identity change" unless
|
|
138
|
+
earlier['chain_identity'] == later['chain_identity']
|
|
139
|
+
# Equal committed extent with different committed roots needs no proof at
|
|
140
|
+
# all: the two states differ at the same size, so no extension relation can
|
|
141
|
+
# exist. This is arithmetic on the committed bindings — a POSITIVE
|
|
142
|
+
# divergence witness (MPR-9), unlike a mere proof-verification failure.
|
|
143
|
+
if earlier['tree_size'] == later['tree_size'] && earlier['cumulative_root'] != later['cumulative_root']
|
|
144
|
+
puts "DIVERGENCE: both bindings commit chain #{later['chain_identity']} at extent " \
|
|
145
|
+
"#{later['tree_size']} with different cumulative roots; conclusive evidence of " \
|
|
146
|
+
'rewriting or forking (MPR-9)'
|
|
147
|
+
exit 1
|
|
148
|
+
end
|
|
149
|
+
require_match(proof, earlier, 'first_root', 'cumulative_root', 'first_root')
|
|
150
|
+
require_match(proof, later, 'second_root', 'cumulative_root', 'second_root')
|
|
151
|
+
require_match(proof, earlier, 'first_size', 'tree_size', 'first_size')
|
|
152
|
+
require_match(proof, later, 'second_size', 'tree_size', 'second_size')
|
|
153
|
+
|
|
154
|
+
ok = CC.verify_consistency(
|
|
155
|
+
first_root: earlier['cumulative_root'],
|
|
156
|
+
first_size: earlier['tree_size'],
|
|
157
|
+
second_root: later['cumulative_root'],
|
|
158
|
+
second_size: later['tree_size'],
|
|
159
|
+
path: proof['path']
|
|
160
|
+
)
|
|
161
|
+
# Design §11: failure of an operator-supplied proof is NOT by itself a
|
|
162
|
+
# positive inconsistency witness — report it as unestablished, not as forgery.
|
|
163
|
+
reject 'consistency proof does not verify; extension claim remains UNESTABLISHED ' \
|
|
164
|
+
'(not by itself evidence of rewriting — MPR-9)' unless ok
|
|
165
|
+
|
|
166
|
+
puts "VERIFIED: the later anchored state (tree_size #{later['tree_size']}) commits to an " \
|
|
167
|
+
"extension of the earlier state (tree_size #{earlier['tree_size']}) unchanged as a prefix " \
|
|
168
|
+
"(chain #{later['chain_identity']})."
|
|
169
|
+
|
|
170
|
+
else
|
|
171
|
+
die "usage: khab_verify.rb {inclusion|consistency} <proof.json> <binding(s).json...>"
|
|
172
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# map_verify — offline auditor verifier for map-1 mutual-anchoring artifacts
|
|
5
|
+
# (MAP-1..4, aud_l2_mutual_anchoring_design v0.5).
|
|
6
|
+
#
|
|
7
|
+
# Verifies with EXACTLY the disclosed trust base: the artifacts named on the
|
|
8
|
+
# command line plus the shipped map-1 convention definition. No registry, no
|
|
9
|
+
# network, no operator cooperation. (Authentic log/chain views are the
|
|
10
|
+
# caller's inputs; their own integrity is checked by the khab-1 surface.)
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# map_verify.rb credential <credential.json>
|
|
14
|
+
# map_verify.rb attestation <credential.json> <payload-file> <signature-hex>
|
|
15
|
+
# map_verify.rb succession <old_credential.json> <records.json> [changeover_position]
|
|
16
|
+
# records.json = JSON array of internal-chain record STRINGS, committed order
|
|
17
|
+
# map_verify.rb coverage <rule-file> <anchors.json> [chain_extent] [rule_position] [rule_moment] [now]
|
|
18
|
+
# anchors.json = JSON array of {"tree_size":..,"moment":".."} observations;
|
|
19
|
+
# every_n_records needs chain_extent (+ optional rule_position);
|
|
20
|
+
# every_n_days needs rule_moment and now (ISO8601) as args 5 and 6 —
|
|
21
|
+
# pass 0 0 for the unused args 3 and 4.
|
|
22
|
+
# map_verify.rb pair <log_a.json> <identity_a> <log_b.json> <identity_b>
|
|
23
|
+
# log_*.json = JSON array of anchor-entry objects (entry.to_h shape);
|
|
24
|
+
# reports what a verified pair establishes (MAP-1) — and ONLY that.
|
|
25
|
+
# map_verify.rb retraction <log.json> <retraction_entry_hash>
|
|
26
|
+
# checks map-1 §3 coherence of an anchor-log retraction against its target.
|
|
27
|
+
#
|
|
28
|
+
# Exit status: 0 = VERIFIED/REPORT, 1 = REJECTED, 2 = usage / unresolvable.
|
|
29
|
+
|
|
30
|
+
require 'json'
|
|
31
|
+
require 'digest'
|
|
32
|
+
require_relative '../lib/synoptis/anchoring/entry'
|
|
33
|
+
require_relative '../lib/synoptis/anchoring/chain_credential'
|
|
34
|
+
require_relative '../lib/synoptis/anchoring/succession'
|
|
35
|
+
require_relative '../lib/synoptis/anchoring/anchoring_rule'
|
|
36
|
+
require_relative '../lib/synoptis/anchoring/attestation_types'
|
|
37
|
+
|
|
38
|
+
ENTRY = Synoptis::Anchoring::Entry
|
|
39
|
+
CRED = Synoptis::Anchoring::ChainCredential
|
|
40
|
+
SUCC = Synoptis::Anchoring::Succession
|
|
41
|
+
RULE = Synoptis::Anchoring::AnchoringRule
|
|
42
|
+
ATYPE = Synoptis::Anchoring::AttestationTypes
|
|
43
|
+
|
|
44
|
+
def die(msg)
|
|
45
|
+
warn "map_verify: #{msg}"
|
|
46
|
+
exit 2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reject(msg)
|
|
50
|
+
puts "REJECTED: #{msg}"
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def load_json(path)
|
|
55
|
+
JSON.parse(File.read(path))
|
|
56
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
57
|
+
die "cannot read #{path}: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Malformed caller input is unresolvable (exit 2), never a verdict.
|
|
61
|
+
# Explicit base 10: Integer('010') would read octal and silently shift a
|
|
62
|
+
# governance boundary.
|
|
63
|
+
def strict_int(value, label)
|
|
64
|
+
# Digits only: Integer() would also accept '1_0' and padded whitespace.
|
|
65
|
+
die "#{label} must be a base-10 integer, got #{value.inspect}" unless value.is_a?(String) && value.match?(/\A\d+\z/)
|
|
66
|
+
Integer(value, 10)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def load_json_hash(path, label)
|
|
70
|
+
parsed = load_json(path)
|
|
71
|
+
die "#{path}: #{label} must be a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
|
|
72
|
+
parsed
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
HEX64 = /\A[a-f0-9]{64}\z/
|
|
76
|
+
|
|
77
|
+
# Structural well-formedness of a matched head binding (khab-1 §4 shapes).
|
|
78
|
+
# The strongest claim this tool prints must be unreachable from malformed
|
|
79
|
+
# input: a garbage binding is unresolvable, never PAIR ESTABLISHED.
|
|
80
|
+
def require_binding_wellformed!(binding, where)
|
|
81
|
+
die "#{where}: head_binding.convention is #{binding['convention'].inspect}, not khab-1" unless binding['convention'] == 'khab-1'
|
|
82
|
+
%w[convention_sha256 cumulative_root].each do |k|
|
|
83
|
+
die "#{where}: head_binding.#{k} missing or not 64-char lowercase hex" unless binding[k].is_a?(String) && binding[k].match?(HEX64)
|
|
84
|
+
end
|
|
85
|
+
die "#{where}: head_binding.tree_size must be a positive Integer" unless binding['tree_size'].is_a?(Integer) && binding['tree_size'].positive?
|
|
86
|
+
unless binding['chain_identity'].is_a?(String) && binding['chain_identity'].match?(/\Ablock1-sha256:[a-f0-9]{64}\z/)
|
|
87
|
+
die "#{where}: head_binding.chain_identity is not block1-sha256:<64-hex> (khab-1 §5)"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Recompute an entry's committed hash from its canonical content. Detects an
|
|
92
|
+
# in-place edited entry without any chain access; the full log hash chain is
|
|
93
|
+
# the khab-1 surface's business (khab_verify / public verifier).
|
|
94
|
+
def require_entry_hash!(entry_h, where)
|
|
95
|
+
content = { 'position' => entry_h['position'], 'prev' => entry_h['prev'],
|
|
96
|
+
'kind' => entry_h['kind'], 'body' => entry_h['body'] }
|
|
97
|
+
recomputed = ENTRY.compute_hash(content)
|
|
98
|
+
return if recomputed == entry_h['entry_hash']
|
|
99
|
+
|
|
100
|
+
reject "#{where}: entry_hash does not recompute from committed content (edited in place?)"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
mode = ARGV.shift
|
|
104
|
+
|
|
105
|
+
case mode
|
|
106
|
+
when 'credential'
|
|
107
|
+
die 'usage: map_verify.rb credential <credential.json>' unless ARGV.size == 1
|
|
108
|
+
cred = load_json_hash(ARGV[0], 'credential')
|
|
109
|
+
begin
|
|
110
|
+
CRED.validate!(cred)
|
|
111
|
+
rescue CRED::CredentialError => e
|
|
112
|
+
reject e.message
|
|
113
|
+
end
|
|
114
|
+
puts "VERIFIED: credential digest #{CRED.credential_digest(cred)} binds #{cred['chain_identity']}"
|
|
115
|
+
puts 'NOTE: binding is a SELF-attestation (map-1 §1); that the credential speaks'
|
|
116
|
+
puts 'for the chain requires the chain to commit the credential digest (map-1 §2).'
|
|
117
|
+
|
|
118
|
+
when 'attestation'
|
|
119
|
+
die 'usage: map_verify.rb attestation <credential.json> <payload-file> <signature-hex>' unless ARGV.size == 3
|
|
120
|
+
cred = load_json_hash(ARGV[0], 'credential')
|
|
121
|
+
payload = begin
|
|
122
|
+
File.binread(ARGV[1])
|
|
123
|
+
rescue SystemCallError => e
|
|
124
|
+
die "cannot read #{ARGV[1]}: #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
begin
|
|
127
|
+
ok = CRED.verify_attestation(cred, payload, ARGV[2])
|
|
128
|
+
rescue CRED::CredentialError => e
|
|
129
|
+
reject e.message
|
|
130
|
+
end
|
|
131
|
+
reject 'attestation signature does not verify under the credential' unless ok
|
|
132
|
+
puts "VERIFIED: attestation signature valid under credential #{CRED.credential_digest(cred)[0, 12]}…"
|
|
133
|
+
|
|
134
|
+
when 'succession'
|
|
135
|
+
die 'usage: map_verify.rb succession <old_credential.json> <records.json> [changeover_position]' unless (2..3).cover?(ARGV.size)
|
|
136
|
+
cred = load_json_hash(ARGV[0], 'credential')
|
|
137
|
+
records = load_json(ARGV[1])
|
|
138
|
+
unless records.is_a?(Array) && records.all? { |r| r.is_a?(String) }
|
|
139
|
+
die "#{ARGV[1]}: expected a JSON array of record STRINGS (each element the exact committed record string)"
|
|
140
|
+
end
|
|
141
|
+
changeover = ARGV[2].nil? ? nil : strict_int(ARGV[2], 'changeover_position')
|
|
142
|
+
die 'changeover_position must be non-negative' if !changeover.nil? && changeover.negative?
|
|
143
|
+
begin
|
|
144
|
+
verdict = SUCC.governance(cred, records, changeover_position: changeover)
|
|
145
|
+
rescue CRED::CredentialError => e
|
|
146
|
+
reject e.message
|
|
147
|
+
end
|
|
148
|
+
puts JSON.pretty_generate(verdict)
|
|
149
|
+
puts 'NOTE: the verdict reports what the records show, never intent (key'
|
|
150
|
+
puts 'compromise is indistinguishable from the issuer — MAP-2 disclosed limit).'
|
|
151
|
+
|
|
152
|
+
when 'coverage'
|
|
153
|
+
unless (2..6).cover?(ARGV.size)
|
|
154
|
+
die 'usage: map_verify.rb coverage <rule-file> <anchors.json> [chain_extent] [rule_position] [rule_moment] [now]'
|
|
155
|
+
end
|
|
156
|
+
rule_string = begin
|
|
157
|
+
File.read(ARGV[0]).strip
|
|
158
|
+
rescue SystemCallError => e
|
|
159
|
+
die "cannot read #{ARGV[0]}: #{e.message}"
|
|
160
|
+
end
|
|
161
|
+
rule = begin
|
|
162
|
+
RULE.parse!(rule_string)
|
|
163
|
+
rescue RULE::RuleError => e
|
|
164
|
+
reject e.message
|
|
165
|
+
end
|
|
166
|
+
anchors = load_json(ARGV[1])
|
|
167
|
+
die "#{ARGV[1]}: expected a JSON array of observations" unless anchors.is_a?(Array)
|
|
168
|
+
extent = ARGV[2].nil? ? nil : strict_int(ARGV[2], 'chain_extent')
|
|
169
|
+
rule_pos = ARGV[3].nil? ? nil : strict_int(ARGV[3], 'rule_position')
|
|
170
|
+
rule_moment = ARGV[4]
|
|
171
|
+
now = ARGV[5]
|
|
172
|
+
# A tool capability gap is a usage error (exit 2), never a REJECTED verdict.
|
|
173
|
+
if rule['trigger'] == 'every_n_records' && extent.nil?
|
|
174
|
+
die 'every_n_records coverage needs chain_extent'
|
|
175
|
+
end
|
|
176
|
+
if rule['trigger'] == 'every_n_days' && (rule_moment.nil? || now.nil?)
|
|
177
|
+
die 'every_n_days coverage needs rule_moment and now (ISO8601), as arguments 5 and 6'
|
|
178
|
+
end
|
|
179
|
+
begin
|
|
180
|
+
report = RULE.coverage(rule_string, anchors, chain_extent: extent, rule_position: rule_pos,
|
|
181
|
+
rule_moment: rule_moment, now: now)
|
|
182
|
+
rescue RULE::RuleError => e
|
|
183
|
+
reject e.message
|
|
184
|
+
end
|
|
185
|
+
puts JSON.pretty_generate(report)
|
|
186
|
+
|
|
187
|
+
when 'pair'
|
|
188
|
+
die 'usage: map_verify.rb pair <log_a.json> <identity_a> <log_b.json> <identity_b>' unless ARGV.size == 4
|
|
189
|
+
log_a, id_a, log_b, id_b = load_json(ARGV[0]), ARGV[1], load_json(ARGV[2]), ARGV[3]
|
|
190
|
+
[[log_a, ARGV[0]], [log_b, ARGV[2]]].each do |(log, path)|
|
|
191
|
+
die "#{path}: expected a JSON array of anchor-entry objects" unless log.is_a?(Array)
|
|
192
|
+
end
|
|
193
|
+
find = lambda do |log, identity, where|
|
|
194
|
+
log.each_with_index.filter_map do |entry, i|
|
|
195
|
+
e = entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : {}
|
|
196
|
+
# Only anchor entries carry head bindings; anything else shaped to look
|
|
197
|
+
# like one never counts toward a pair.
|
|
198
|
+
next unless e['kind'] == 'anchor'
|
|
199
|
+
|
|
200
|
+
body = e['body'].is_a?(Hash) ? e['body'] : {}
|
|
201
|
+
hb = body['head_binding']
|
|
202
|
+
next unless hb.is_a?(Hash) && hb['chain_identity'] == identity
|
|
203
|
+
|
|
204
|
+
require_binding_wellformed!(hb, "#{where} entry #{e['position'] || i}")
|
|
205
|
+
require_entry_hash!(e, "#{where} entry #{e['position'] || i}")
|
|
206
|
+
{ position: e['position'] || i, cumulative_root: hb['cumulative_root'],
|
|
207
|
+
tree_size: hb['tree_size'], attestation_type: body['attestation_type'] }
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
a_holds_b = find.call(log_a, id_b, ARGV[0])
|
|
211
|
+
b_holds_a = find.call(log_b, id_a, ARGV[2])
|
|
212
|
+
reject "no entry in #{ARGV[0]} commits a head binding of #{id_b}" if a_holds_b.empty?
|
|
213
|
+
reject "no entry in #{ARGV[2]} commits a head binding of #{id_a}" if b_holds_a.empty?
|
|
214
|
+
puts 'PAIR ESTABLISHED (MAP-1 — exactly this, nothing stronger):'
|
|
215
|
+
puts " #{id_b} head(s) committed in log A at #{a_holds_b.map { |e| e[:position] }.join(', ')}"
|
|
216
|
+
puts " #{id_a} head(s) committed in log B at #{b_holds_a.map { |e| e[:position] }.join(', ')}"
|
|
217
|
+
untyped = (a_holds_b + b_holds_a).count { |e| e[:attestation_type].nil? }
|
|
218
|
+
puts " untyped (pre-map-1, grandfathered) inscriptions: #{untyped}" if untyped.positive?
|
|
219
|
+
puts 'CONDITIONS (travel with every stronger reading, map-1 §6):'
|
|
220
|
+
puts ' - partner independence is auditor-supplied; a common operator can fabricate the pair'
|
|
221
|
+
puts ' - temporal weight reaches only as far as each log\'s own khab-1 anchoring'
|
|
222
|
+
puts ' - equivocation detection requires authentic views of BOTH logs and cross-comparison'
|
|
223
|
+
puts ' - matched entry hashes recomputed and binding shapes checked here; full log hash-chain'
|
|
224
|
+
puts ' and cumulative-root verification are the khab-1 surface\'s business (khab_verify)'
|
|
225
|
+
puts ' - this pair supplies split-view detection material; it closes nothing'
|
|
226
|
+
|
|
227
|
+
when 'retraction'
|
|
228
|
+
die 'usage: map_verify.rb retraction <log.json> <retraction_entry_hash>' unless ARGV.size == 2
|
|
229
|
+
log = load_json(ARGV[0])
|
|
230
|
+
die "#{ARGV[0]}: expected a JSON array of anchor-entry objects" unless log.is_a?(Array)
|
|
231
|
+
entries = log.map { |e| e.is_a?(Hash) ? e.transform_keys(&:to_s) : {} }
|
|
232
|
+
retraction = entries.find { |e| e['entry_hash'] == ARGV[1] }
|
|
233
|
+
die "no entry with entry_hash #{ARGV[1]} in #{ARGV[0]}" if retraction.nil?
|
|
234
|
+
body = retraction['body'].is_a?(Hash) ? retraction['body'] : {}
|
|
235
|
+
meta = body['metadata'].is_a?(Hash) ? body['metadata'] : {}
|
|
236
|
+
target_ref = meta['target_entry_hash']
|
|
237
|
+
unless target_ref.is_a?(String) && target_ref.match?(HEX64)
|
|
238
|
+
reject "retraction carries no resolvable metadata.target_entry_hash (got #{target_ref.inspect})"
|
|
239
|
+
end
|
|
240
|
+
target = entries.find { |e| e['entry_hash'] == target_ref }
|
|
241
|
+
reject "target entry #{target_ref} not present in the supplied log view" if target.nil?
|
|
242
|
+
require_entry_hash!(retraction, 'retraction')
|
|
243
|
+
require_entry_hash!(target, 'target')
|
|
244
|
+
verdict = ATYPE.retraction_coherence(retraction, target)
|
|
245
|
+
puts JSON.pretty_generate(verdict)
|
|
246
|
+
exit(verdict[:coherent] ? 0 : 1)
|
|
247
|
+
|
|
248
|
+
else
|
|
249
|
+
die 'usage: map_verify.rb credential|attestation|succession|coverage|pair|retraction ... (see header)'
|
|
250
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
require_relative 'entry'
|
|
7
|
+
|
|
8
|
+
module Synoptis
|
|
9
|
+
module Anchoring
|
|
10
|
+
# Declared anchoring rule (aud_l2_mutual_anchoring_design v0.5 MAP-3)
|
|
11
|
+
# under map-1 §5: a result-free rule artifact whose schema is closed by
|
|
12
|
+
# construction (no field can reference an outcome), committed on the chain
|
|
13
|
+
# for decidable anteriority, and a coverage CHECKER that reports — never
|
|
14
|
+
# enforces. A sparse or vacuous rule conforms; the report makes its cost
|
|
15
|
+
# visible. No automation lives here (cadence automation is out of this
|
|
16
|
+
# slice; the canonical-works human gate is untouched).
|
|
17
|
+
module AnchoringRule
|
|
18
|
+
FORMAT = 'map-1/anchoring-rule'
|
|
19
|
+
COMMITMENT_FORMAT = 'map-1/rule-commitment'
|
|
20
|
+
TRIGGERS = %w[every_n_records every_n_days].freeze
|
|
21
|
+
FIELDS = %w[format n trigger].freeze
|
|
22
|
+
HEX_DIGEST = /\A[a-f0-9]{64}\z/
|
|
23
|
+
# JSON-safe bound, matching HeadBinding (ANC-2 boundedness).
|
|
24
|
+
JSON_SAFE_BOUND = 2**53
|
|
25
|
+
|
|
26
|
+
class RuleError < StandardError; end
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Build a rule artifact string (canonical JSON, map-1 §5).
|
|
31
|
+
def build(trigger, n)
|
|
32
|
+
t = trigger.to_s
|
|
33
|
+
raise RuleError, "trigger must be one of #{TRIGGERS.join(', ')}, got #{t.inspect}" unless TRIGGERS.include?(t)
|
|
34
|
+
unless n.is_a?(Integer) && n.positive? && n < JSON_SAFE_BOUND
|
|
35
|
+
raise RuleError, "n must be a positive JSON-safe Integer, got #{n.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Entry.canonical_json('format' => FORMAT, 'n' => n, 'trigger' => t)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Parse + validate a rule artifact string. Raises RuleError on the first
|
|
42
|
+
# violation (a rule is an operator-authored artifact: malformed means
|
|
43
|
+
# refused, not coerced).
|
|
44
|
+
def parse!(rule_string)
|
|
45
|
+
parsed = begin
|
|
46
|
+
JSON.parse(rule_string.to_s)
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
raise RuleError, "rule is not valid JSON: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
raise RuleError, "rule must be a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
r = parsed.transform_keys(&:to_s)
|
|
53
|
+
keys = r.keys.sort
|
|
54
|
+
raise RuleError, "rule fields must be exactly #{FIELDS.join(', ')}, got #{keys.join(', ')}" unless keys == FIELDS
|
|
55
|
+
raise RuleError, "unknown format #{r['format'].inspect} (#{FORMAT} only)" unless r['format'] == FORMAT
|
|
56
|
+
raise RuleError, "trigger must be one of #{TRIGGERS.join(', ')}" unless TRIGGERS.include?(r['trigger'])
|
|
57
|
+
unless r['n'].is_a?(Integer) && r['n'].positive? && r['n'] < JSON_SAFE_BOUND
|
|
58
|
+
raise RuleError, 'rule.n must be a positive JSON-safe Integer'
|
|
59
|
+
end
|
|
60
|
+
# map-1 §5: a rule is valid only in its canonical serialization, so one
|
|
61
|
+
# rule has exactly one digest — a re-ordered or re-spaced serialization
|
|
62
|
+
# would commit a different digest for the same logical rule.
|
|
63
|
+
unless Entry.canonical_json(r) == rule_string.to_s
|
|
64
|
+
raise RuleError, 'rule is not in canonical serialization (map-1 §5: one rule, one digest)'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
r
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The rule-commitment record string (map-1 §5) the chain commits so the
|
|
71
|
+
# rule's anteriority is decidable in committed order (MPR-8).
|
|
72
|
+
def commitment_record(rule_string)
|
|
73
|
+
parse!(rule_string)
|
|
74
|
+
Entry.canonical_json(
|
|
75
|
+
'format' => COMMITMENT_FORMAT,
|
|
76
|
+
'rule_digest' => Digest::SHA256.hexdigest(rule_string.to_s)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Coverage report (map-1 §5): the rule held against the visible anchor
|
|
81
|
+
# history. Diagnostic posture: never raises on the history side — asked
|
|
82
|
+
# "is this history covered?", the answer is a report, not an exception.
|
|
83
|
+
#
|
|
84
|
+
# +rule_string+ the committed rule artifact.
|
|
85
|
+
# +anchors+ the binding-carrying anchor observations, each a Hash
|
|
86
|
+
# with 'tree_size' (Integer) and 'moment' (ISO8601
|
|
87
|
+
# String) — the shape a reader extracts from head
|
|
88
|
+
# bindings + entry moments.
|
|
89
|
+
# +chain_extent+ current record count of the internal chain.
|
|
90
|
+
# +rule_position+ committed record position of the rule commitment
|
|
91
|
+
# (for every_n_records) — expectation starts after it.
|
|
92
|
+
# +rule_moment+ committed moment of the rule commitment (ISO8601, for
|
|
93
|
+
# every_n_days).
|
|
94
|
+
# +now+ end of the assessed window (ISO8601); required for
|
|
95
|
+
# every_n_days so the checker stays clock-free.
|
|
96
|
+
#
|
|
97
|
+
# Returns { conforms: true, expected: [...], matched: [...], gaps: [...],
|
|
98
|
+
# note: } — `conforms` is always true for a valid rule (MAP-3:
|
|
99
|
+
# a vacuous rule conforms); the gaps are the visible cost.
|
|
100
|
+
def coverage(rule_string, anchors, chain_extent: nil, rule_position: nil, rule_moment: nil, now: nil)
|
|
101
|
+
rule = parse!(rule_string)
|
|
102
|
+
observations = Array(anchors).map { |a| a.is_a?(Hash) ? a.transform_keys(&:to_s) : {} }
|
|
103
|
+
|
|
104
|
+
case rule['trigger']
|
|
105
|
+
when 'every_n_records'
|
|
106
|
+
coverage_by_records(rule, observations, chain_extent, rule_position)
|
|
107
|
+
when 'every_n_days'
|
|
108
|
+
coverage_by_days(rule, observations, rule_moment, now)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# -- internal helpers --
|
|
113
|
+
|
|
114
|
+
def coverage_by_records(rule, observations, chain_extent, rule_position)
|
|
115
|
+
extent = begin
|
|
116
|
+
Integer(chain_extent || 0)
|
|
117
|
+
rescue ArgumentError, ::TypeError
|
|
118
|
+
raise RuleError, "chain_extent must be an Integer, got #{chain_extent.inspect}"
|
|
119
|
+
end
|
|
120
|
+
start = begin
|
|
121
|
+
Integer(rule_position || 0)
|
|
122
|
+
rescue ArgumentError, ::TypeError
|
|
123
|
+
raise RuleError, "rule_position must be an Integer, got #{rule_position.inspect}"
|
|
124
|
+
end
|
|
125
|
+
n = rule['n']
|
|
126
|
+
sizes = observations.map { |o| o['tree_size'] }.select { |s| s.is_a?(Integer) }.sort
|
|
127
|
+
expected = []
|
|
128
|
+
threshold = start + n
|
|
129
|
+
while threshold <= extent
|
|
130
|
+
expected << threshold
|
|
131
|
+
threshold += n
|
|
132
|
+
end
|
|
133
|
+
matched = expected.select { |e| sizes.any? { |s| s >= e && s < e + n } }
|
|
134
|
+
gaps = expected - matched
|
|
135
|
+
{
|
|
136
|
+
conforms: true,
|
|
137
|
+
# Echo the assessed frame so a defaulted rule_position is visible in
|
|
138
|
+
# the report, never silently assumed.
|
|
139
|
+
chain_extent: extent,
|
|
140
|
+
rule_position: start,
|
|
141
|
+
expected: expected,
|
|
142
|
+
matched: matched,
|
|
143
|
+
gaps: gaps,
|
|
144
|
+
note: gaps.empty? ? 'covered' : "#{gaps.size} expected anchor point(s) without a binding-carrying anchor; temporal windows widen accordingly (MPR-7)"
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def coverage_by_days(rule, observations, rule_moment, now)
|
|
149
|
+
raise RuleError, 'every_n_days coverage needs rule_moment and now (ISO8601)' if rule_moment.nil? || now.nil?
|
|
150
|
+
|
|
151
|
+
t0 = Time.iso8601(rule_moment.to_s)
|
|
152
|
+
t1 = Time.iso8601(now.to_s)
|
|
153
|
+
n_sec = rule['n'] * 86_400
|
|
154
|
+
moments = observations.map { |o| o['moment'] }.filter_map do |m|
|
|
155
|
+
Time.iso8601(m.to_s)
|
|
156
|
+
rescue ArgumentError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
expected = []
|
|
160
|
+
boundary = t0 + n_sec
|
|
161
|
+
while boundary <= t1
|
|
162
|
+
expected << boundary.utc.iso8601
|
|
163
|
+
boundary += n_sec
|
|
164
|
+
end
|
|
165
|
+
matched = expected.select do |e|
|
|
166
|
+
et = Time.iso8601(e)
|
|
167
|
+
moments.any? { |m| m >= et && m < et + n_sec }
|
|
168
|
+
end
|
|
169
|
+
gaps = expected - matched
|
|
170
|
+
{
|
|
171
|
+
conforms: true,
|
|
172
|
+
rule_moment: t0.utc.iso8601,
|
|
173
|
+
assessed_until: t1.utc.iso8601,
|
|
174
|
+
expected: expected,
|
|
175
|
+
matched: matched,
|
|
176
|
+
gaps: gaps,
|
|
177
|
+
note: gaps.empty? ? 'covered' : "#{gaps.size} expected anchor window(s) without a binding-carrying anchor; temporal windows widen accordingly (MPR-7)"
|
|
178
|
+
}
|
|
179
|
+
rescue ArgumentError => e
|
|
180
|
+
raise RuleError, "invalid ISO8601 input: #{e.message}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|