reeve 0.5.0 → 0.7.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 +281 -3
- data/README.md +151 -6
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +63 -3
- data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
- data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
- data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +25 -2
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +51 -0
- data/lib/reeve/audit/chain/canonical.rb +144 -0
- data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
- data/lib/reeve/audit/chain/epochs.rb +77 -0
- data/lib/reeve/audit/chain/head.rb +122 -0
- data/lib/reeve/audit/chain/keyring.rb +53 -0
- data/lib/reeve/audit/chain/normalizer.rb +88 -0
- data/lib/reeve/audit/chain/report.rb +177 -0
- data/lib/reeve/audit/chain/rotation.rb +124 -0
- data/lib/reeve/audit/chain/schema.rb +59 -0
- data/lib/reeve/audit/chain/verification.rb +131 -0
- data/lib/reeve/audit/chain/verifier.rb +168 -0
- data/lib/reeve/audit/chain/walk.rb +137 -0
- data/lib/reeve/audit/chain.rb +207 -0
- data/lib/reeve/audit/chain_epoch.rb +24 -0
- data/lib/reeve/audit/chain_record.rb +66 -0
- data/lib/reeve/audit/isolated_recorder.rb +5 -0
- data/lib/reeve/audit/recorder.rb +32 -19
- data/lib/reeve/audit.rb +37 -6
- data/lib/reeve/configuration.rb +66 -2
- data/lib/reeve/errors.rb +15 -0
- data/lib/reeve/ledger_key.rb +47 -0
- data/lib/reeve/railtie.rb +16 -0
- data/lib/reeve/tasks.rb +91 -0
- data/lib/reeve/testing/checks/contract_version.rb +2 -1
- data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
- data/lib/reeve/testing/checks.rb +29 -4
- data/lib/reeve/testing/compliance_assertions.rb +3 -2
- data/lib/reeve/testing/compliance_suite.rb +2 -2
- data/lib/reeve/testing/ledger.rb +11 -1
- data/lib/reeve/testing/report.rb +8 -0
- data/lib/reeve/testing.rb +1 -1
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +2 -0
- metadata +24 -2
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# The keys one chained write or one verification may use (specs/002 FR-011, R7).
|
|
7
|
+
#
|
|
8
|
+
# One key signs; any number verify. Rows name their key by fingerprint, so lookup is
|
|
9
|
+
# by fingerprint too, and a key the ring does not hold is simply absent — the verifier
|
|
10
|
+
# turns that into "unverifiable", never into "tampered".
|
|
11
|
+
#
|
|
12
|
+
# Keyring.from(Reeve.config) # what the recorder signs with
|
|
13
|
+
# Keyring.new(signing: nil, retired: [auditor_key]) # verify-only, e.g. an auditor
|
|
14
|
+
class Keyring
|
|
15
|
+
def self.from(config)
|
|
16
|
+
new(signing: config.ledger_key, retired: config.retired_ledger_keys)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(signing:, retired: [])
|
|
20
|
+
@signing_secret = signing
|
|
21
|
+
@by_id = {}
|
|
22
|
+
[signing, *retired].compact.each do |secret|
|
|
23
|
+
@by_id[LedgerKey.fingerprint(secret)] ||= secret
|
|
24
|
+
end
|
|
25
|
+
@by_id.freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :signing_secret
|
|
30
|
+
|
|
31
|
+
def signing_id
|
|
32
|
+
signing_secret && LedgerKey.fingerprint(signing_secret)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def signing?
|
|
36
|
+
!signing_secret.nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def for(key_id)
|
|
40
|
+
@by_id[key_id]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def empty?
|
|
44
|
+
@by_id.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class.name} signing=#{signing_id.inspect} keys=#{@by_id.keys.inspect}>"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# Puts a row into the form the database will hand back, before it is written
|
|
7
|
+
# (specs/002 FR-003, research R2 step 1).
|
|
8
|
+
#
|
|
9
|
+
# The digest is computed at write time and recomputed at verification from what the
|
|
10
|
+
# database returns. The two only agree if nothing is left for the database to change,
|
|
11
|
+
# so anything it would change is changed here first:
|
|
12
|
+
#
|
|
13
|
+
# * JSON values go through the column's own ActiveRecord type, encode and decode — the
|
|
14
|
+
# exact path a write takes. Symbol keys become strings, a Time inside the arguments
|
|
15
|
+
# becomes the string ActiveRecord stores, NaN becomes nil.
|
|
16
|
+
# * `occurred_at` is floored to the precision the column keeps, so the database has
|
|
17
|
+
# no fraction to round (MySQL rounds; it does not truncate), and moved to UTC.
|
|
18
|
+
#
|
|
19
|
+
# What remains — JSON key order and how a number is spelled — is the database's to
|
|
20
|
+
# change, and Canonical makes it irrelevant.
|
|
21
|
+
#
|
|
22
|
+
# It cannot recover a value the database alters for good: an integer beyond double
|
|
23
|
+
# precision in a MySQL JSON column is stored rounded. That shows up as a false break,
|
|
24
|
+
# never a false pass, and is documented.
|
|
25
|
+
class Normalizer
|
|
26
|
+
JSON_FIELDS = %i[arguments record_ids metadata].freeze
|
|
27
|
+
TIME_FIELD = :occurred_at
|
|
28
|
+
|
|
29
|
+
# Undeclared precision: MySQL's DATETIME is whole seconds, the others keep
|
|
30
|
+
# microseconds.
|
|
31
|
+
WHOLE_SECOND_ADAPTERS = /mysql|trilogy/i
|
|
32
|
+
DEFAULT_PRECISION = 6
|
|
33
|
+
|
|
34
|
+
def self.effective_precision(column, adapter_name)
|
|
35
|
+
return column.precision unless column.precision.nil?
|
|
36
|
+
|
|
37
|
+
adapter_name.to_s.match?(WHOLE_SECOND_ADAPTERS) ? 0 : DEFAULT_PRECISION
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(entry_class)
|
|
41
|
+
@entry_class = entry_class
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A new hash; the row passed in is not modified.
|
|
45
|
+
def call(row)
|
|
46
|
+
# Symbol keys throughout, whatever the caller built: a host recorder (FR-007) may use
|
|
47
|
+
# strings, and every lookup below — and the replay check after it — is by symbol.
|
|
48
|
+
normalized = row.to_h.transform_keys(&:to_sym)
|
|
49
|
+
JSON_FIELDS.each do |field|
|
|
50
|
+
next unless normalized.key?(field)
|
|
51
|
+
|
|
52
|
+
normalized[field] = round_trip(field, normalized[field])
|
|
53
|
+
end
|
|
54
|
+
normalized[TIME_FIELD] = floor(normalized[TIME_FIELD]) if normalized[TIME_FIELD]
|
|
55
|
+
normalized
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
attr_reader :entry_class
|
|
61
|
+
|
|
62
|
+
def round_trip(field, value)
|
|
63
|
+
return nil if value.nil?
|
|
64
|
+
|
|
65
|
+
type = json_types[field]
|
|
66
|
+
type.deserialize(type.serialize(value))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def floor(time)
|
|
70
|
+
time.to_time.getutc.floor(precision)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def json_types
|
|
74
|
+
@json_types ||= JSON_FIELDS.to_h do |field|
|
|
75
|
+
[field, entry_class.type_for_attribute(field.to_s)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def precision
|
|
80
|
+
@precision ||= self.class.effective_precision(
|
|
81
|
+
entry_class.columns_hash.fetch(TIME_FIELD.to_s),
|
|
82
|
+
entry_class.connection_db_config.adapter
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# A Verification in words (contracts/ledger-chain.md "Output").
|
|
7
|
+
#
|
|
8
|
+
# Every summary line carries its own coverage. A reader who copies only the first line
|
|
9
|
+
# into a ticket still copies how much of the ledger the word in it covers — "INTACT"
|
|
10
|
+
# never travels without "sequences 1–n" and the count of what was not covered.
|
|
11
|
+
#
|
|
12
|
+
# Unknown-key breaks are summarized, not listed: there is one per row a missing key
|
|
13
|
+
# signed, and the summary already names the key and the count.
|
|
14
|
+
class Report
|
|
15
|
+
LABEL_WIDTH = 14
|
|
16
|
+
KIND_WIDTH = 17
|
|
17
|
+
PREFIX = "reeve ledger:"
|
|
18
|
+
|
|
19
|
+
def self.number(value)
|
|
20
|
+
digits = value.abs.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
21
|
+
value.negative? ? "-#{digits}" : digits
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(verification)
|
|
25
|
+
@v = verification
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s(limit:)
|
|
29
|
+
listed = @v.breaks.reject { |found| found.kind == :unknown_key }
|
|
30
|
+
epochs = EpochLines.new(@v).to_a
|
|
31
|
+
return [summary, *epochs].join("\n") if listed.empty?
|
|
32
|
+
|
|
33
|
+
lines = listed.first(limit).map { |found| line_for(found) }
|
|
34
|
+
[summary, totals, *epochs, *lines, more(listed, limit)].compact.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def summary
|
|
40
|
+
case @v.status
|
|
41
|
+
when :intact then intact
|
|
42
|
+
when :broken then broken
|
|
43
|
+
when :no_chain then no_chain
|
|
44
|
+
else @v.reason == :no_key ? no_key : unverifiable
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def intact
|
|
49
|
+
"#{PREFIX} INTACT — sequences #{range(@v.covered)} verified " \
|
|
50
|
+
"(head at start: #{number(@v.head_at_start)}); #{uncovered}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def broken
|
|
54
|
+
count = @v.breaks.size
|
|
55
|
+
where = @v.covered ? "in sequences #{range(@v.covered)}" : "in the ledger"
|
|
56
|
+
"#{PREFIX} BROKEN — #{number(count)} break#{'s' unless count == 1} #{where}; #{uncovered}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def no_chain
|
|
60
|
+
"#{PREFIX} NO CHAIN — #{number(@v.uncovered_now + @v.unchained_now)} entries, " \
|
|
61
|
+
"none chained; nothing was verified"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def no_key
|
|
65
|
+
"#{PREFIX} UNVERIFIABLE — no ledger key configured; nothing was verified"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unverifiable
|
|
69
|
+
unknown = @v.breaks.select { |found| found.kind == :unknown_key }
|
|
70
|
+
rows = unknown.count { |found| entry_position?(found.sequence) }
|
|
71
|
+
checked = @v.covered ? @v.covered.size - rows : 0
|
|
72
|
+
"#{PREFIX} UNVERIFIABLE — #{number(rows)} entries signed by #{missing_keys(unknown)} " \
|
|
73
|
+
"not configured; the other #{number(checked)} in #{range(@v.covered)} show no " \
|
|
74
|
+
"breaks, which is not a pass; #{number(@v.uncovered_now)} earlier entries not covered"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The genesis is position 0 and is not an entry.
|
|
78
|
+
def entry_position?(sequence)
|
|
79
|
+
sequence.is_a?(Integer) && sequence.positive?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def missing_keys(unknown)
|
|
83
|
+
keys = unknown.map(&:key_id).compact.uniq
|
|
84
|
+
keys.one? ? "key #{keys.first}, which is" : "keys #{keys.join(', ')}, which are"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def uncovered
|
|
88
|
+
parts = ["#{number(@v.uncovered_now)} earlier entries not covered"]
|
|
89
|
+
at_genesis = @v.uncovered_at_genesis
|
|
90
|
+
parts << " (#{number(at_genesis)} when the chain started)" if at_genesis
|
|
91
|
+
unless @v.unchained_at_genesis.to_i.zero?
|
|
92
|
+
parts << "; #{number(@v.unchained_at_genesis)} unchained entries from before the " \
|
|
93
|
+
"chain started, not covered"
|
|
94
|
+
end
|
|
95
|
+
parts.join
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def totals
|
|
99
|
+
" totals: #{@v.totals.map { |kind, count| "#{kind} #{count}" }.join(', ')}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def more(listed, limit)
|
|
103
|
+
remaining = listed.size - limit
|
|
104
|
+
return nil unless limit.positive? && remaining.positive?
|
|
105
|
+
|
|
106
|
+
" … and #{number(remaining)} more (the totals above are complete)"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def line_for(found)
|
|
110
|
+
kind = found.kind.to_s.ljust(KIND_WIDTH)
|
|
111
|
+
" #{label(found).ljust(LABEL_WIDTH)}#{kind}#{found.detail}".rstrip
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def label(found)
|
|
115
|
+
sequence = found.sequence
|
|
116
|
+
return "invocation #{found.invocation_id}" if found.invocation_id
|
|
117
|
+
return "epoch #{found.epoch}" if sequence.nil? && found.epoch
|
|
118
|
+
return "—" if sequence.nil?
|
|
119
|
+
return "##{range(sequence)}" if sequence.is_a?(Range)
|
|
120
|
+
|
|
121
|
+
sequence.zero? ? "genesis" : "##{number(sequence)}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def range(value)
|
|
125
|
+
return "—" if value.nil?
|
|
126
|
+
|
|
127
|
+
"#{number(value.first)}–#{number(value.last)}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def number(value)
|
|
131
|
+
self.class.number(value.to_i)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The key epochs, one line each (specs/003 FR-013), shown once they say something the
|
|
136
|
+
# summary does not: more than one, one that is not verified, or rows chained before the
|
|
137
|
+
# first. A ledger with one verified epoch and nothing before it reads as 0.6.0's did.
|
|
138
|
+
class EpochLines
|
|
139
|
+
STATUS_WORDS = {
|
|
140
|
+
verified: "verified", key_not_configured: "key not configured", altered: "altered"
|
|
141
|
+
}.freeze
|
|
142
|
+
|
|
143
|
+
def initialize(verification)
|
|
144
|
+
@epochs = verification.epochs
|
|
145
|
+
@before = verification.before_first_epoch.to_i
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def to_a
|
|
149
|
+
return [] unless shown?
|
|
150
|
+
|
|
151
|
+
lines = @epochs.map { |epoch| line(epoch) }
|
|
152
|
+
return lines unless @before.positive?
|
|
153
|
+
|
|
154
|
+
[" before epoch 0 #{entries(@before)} chained before key epochs began", *lines]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def shown?
|
|
160
|
+
return false if @epochs.empty?
|
|
161
|
+
|
|
162
|
+
@epochs.size > 1 || @epochs.first.status != :verified || @before.positive?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def line(epoch)
|
|
166
|
+
" epoch #{epoch.epoch} key #{epoch.key_id} " \
|
|
167
|
+
"from sequence #{Report.number(epoch.first_sequence)} " \
|
|
168
|
+
"#{entries(epoch.entry_count)} #{STATUS_WORDS.fetch(epoch.status)}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def entries(count)
|
|
172
|
+
"#{Report.number(count)} #{count == 1 ? 'entry' : 'entries'}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# What `bin/rails reeve:rotate_ledger_key` did, or why it did nothing (specs/003 FR-006,
|
|
7
|
+
# FR-009, contracts/key-rotation.md). A value, so a deploy script can act on it without
|
|
8
|
+
# reading the text. Names keys by fingerprint only, never by value (FR-007).
|
|
9
|
+
#
|
|
10
|
+
# :rotated — opened `epoch` with the configured key, from `first_sequence`
|
|
11
|
+
# :current — the configured key opened the latest epoch already; nothing written
|
|
12
|
+
# :refused — nothing written; `reason` is :no_key, :key_reused or :outgoing_key_missing
|
|
13
|
+
class Rotation
|
|
14
|
+
PREFIX = "reeve ledger key:"
|
|
15
|
+
|
|
16
|
+
FIELDS = %i[status reason epoch key_id first_sequence previous_epoch previous_key_id
|
|
17
|
+
warnings].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader(*FIELDS)
|
|
20
|
+
|
|
21
|
+
# Run under the chain record's lock (Chain#rotate_key!). `epochs` writes; `keyring`
|
|
22
|
+
# is this host's — the configured key and the retired ones.
|
|
23
|
+
def self.decide(epochs, keyring, head_sequence:, accept_unverifiable:)
|
|
24
|
+
all = epochs.all
|
|
25
|
+
own = all.find { |epoch| epoch[:key_id] == keyring.signing_id }
|
|
26
|
+
# `own` first: with no epochs at all, nil would equal the missing latest one.
|
|
27
|
+
return current(all, keyring) if own && all.last.equal?(own)
|
|
28
|
+
return refused(:key_reused, epoch: own[:epoch], key_id: own[:key_id]) if own
|
|
29
|
+
|
|
30
|
+
open_next(epochs, keyring, all.last, head_sequence: head_sequence,
|
|
31
|
+
accept_unverifiable: accept_unverifiable)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.refused(reason, **fields)
|
|
35
|
+
new(status: :refused, reason: reason, **fields)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.open_next(epochs, keyring, latest, head_sequence:, accept_unverifiable:)
|
|
39
|
+
lost = latest && keyring.for(latest[:key_id]).nil?
|
|
40
|
+
return refused(:outgoing_key_missing, **previous(latest)) if lost && !accept_unverifiable
|
|
41
|
+
|
|
42
|
+
opened = epochs.open!(keyring, first_sequence: head_sequence + 1)
|
|
43
|
+
new(status: :rotated, epoch: opened[:epoch], key_id: opened[:key_id],
|
|
44
|
+
first_sequence: opened[:first_sequence], **previous(latest),
|
|
45
|
+
warnings: lost ? [unverifiable(latest)] : [])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.current(all, keyring)
|
|
49
|
+
latest = all.last
|
|
50
|
+
before = all[-2]
|
|
51
|
+
lost = before && keyring.for(before[:key_id]).nil?
|
|
52
|
+
new(status: :current, epoch: latest[:epoch], key_id: latest[:key_id],
|
|
53
|
+
first_sequence: latest[:first_sequence], **previous(before),
|
|
54
|
+
warnings: lost ? [unverifiable(before)] : [])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.previous(epoch)
|
|
58
|
+
epoch ? { previous_epoch: epoch[:epoch], previous_key_id: epoch[:key_id] } : {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.unverifiable(epoch)
|
|
62
|
+
"epoch #{epoch[:epoch]}'s key #{epoch[:key_id]} is not configured on this host, so " \
|
|
63
|
+
"its entries are unverifiable here"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method :open_next, :current, :previous, :unverifiable
|
|
67
|
+
|
|
68
|
+
def initialize(status:, warnings: [], **fields)
|
|
69
|
+
@status = status
|
|
70
|
+
@warnings = warnings.freeze
|
|
71
|
+
(FIELDS - %i[status warnings]).each do |name|
|
|
72
|
+
instance_variable_set(:"@#{name}", fields[name])
|
|
73
|
+
end
|
|
74
|
+
freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rotated? = status == :rotated
|
|
78
|
+
def current? = status == :current
|
|
79
|
+
def refused? = status == :refused
|
|
80
|
+
|
|
81
|
+
def to_s
|
|
82
|
+
[first_line, *warnings.map { |warning| "warning: #{warning}" }].join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def first_line
|
|
88
|
+
case status
|
|
89
|
+
when :rotated then "#{PREFIX} ROTATED — #{rotated_text}"
|
|
90
|
+
when :current
|
|
91
|
+
"#{PREFIX} CURRENT — epoch #{epoch} already signs with the configured key " \
|
|
92
|
+
"#{key_id}; nothing to do"
|
|
93
|
+
else "#{PREFIX} REFUSED — #{refusal}; nothing was written"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def rotated_text
|
|
98
|
+
text = "epoch #{epoch} opened with key #{key_id} from sequence " \
|
|
99
|
+
"#{Report.number(first_sequence)}"
|
|
100
|
+
return text if previous_epoch.nil?
|
|
101
|
+
|
|
102
|
+
previous = "epoch #{previous_epoch} (key #{previous_key_id})"
|
|
103
|
+
return "#{text}; the previous epoch was #{previous}" if warnings.any?
|
|
104
|
+
|
|
105
|
+
"#{text}; epoch #{previous_epoch} (key #{previous_key_id}) stays verifiable while " \
|
|
106
|
+
"that key is in retired_ledger_keys"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def refusal
|
|
110
|
+
case reason
|
|
111
|
+
when :no_key then "no ledger key configured (config.ledger_key)"
|
|
112
|
+
when :key_reused
|
|
113
|
+
"the configured key #{key_id} already opened epoch #{epoch}; a key opens one " \
|
|
114
|
+
"epoch only — configure a new key"
|
|
115
|
+
else
|
|
116
|
+
"epoch #{previous_epoch}'s key #{previous_key_id} is not configured on this host, " \
|
|
117
|
+
"so its entries would become unverifiable here — add it to " \
|
|
118
|
+
"config.retired_ledger_keys, or run with ACCEPT_UNVERIFIABLE=1 if that key is lost"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# Whether the ledger has the shape this reeve writes: every chain column and, from
|
|
7
|
+
# contract 4, the epochs table (specs/003 FR-018). A table one contract behind fails
|
|
8
|
+
# with the fix named.
|
|
9
|
+
#
|
|
10
|
+
# The columns are checked before every write — ActiveRecord caches them, so that costs
|
|
11
|
+
# nothing. The epochs table is not: asking the catalog would be a query per guarded
|
|
12
|
+
# call (FR-019b), so its absence is diagnosed only once the database has refused a
|
|
13
|
+
# write that needed it.
|
|
14
|
+
class Schema
|
|
15
|
+
def initialize(entry_class)
|
|
16
|
+
@entry_class = entry_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def require_columns!
|
|
20
|
+
missing = COLUMNS - entry_class.column_names
|
|
21
|
+
outdated!("#{entry_class.table_name} is missing #{missing.join(', ')}") if missing.any?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Runs the write; if the database refuses it, names a missing epochs table as the
|
|
25
|
+
# cause. Recorder builds a Chain per write, so asking up front would be a catalog
|
|
26
|
+
# query per guarded call.
|
|
27
|
+
def explaining
|
|
28
|
+
yield
|
|
29
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
30
|
+
explain!(e)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Re-raises `error` unless the epochs table is what the ledger lacks. A ledger with no
|
|
34
|
+
# entries table at all is not an older contract but a missing ledger, and keeps the
|
|
35
|
+
# database's own error.
|
|
36
|
+
def explain!(error)
|
|
37
|
+
missing = entry_class.connection_pool.with_connection do |connection|
|
|
38
|
+
!connection.table_exists?(EPOCHS_TABLE) &&
|
|
39
|
+
connection.table_exists?(entry_class.table_name)
|
|
40
|
+
end
|
|
41
|
+
raise error unless missing
|
|
42
|
+
|
|
43
|
+
outdated!("there is no #{EPOCHS_TABLE} table")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :entry_class
|
|
49
|
+
|
|
50
|
+
def outdated!(problem)
|
|
51
|
+
raise ConfigurationError,
|
|
52
|
+
"#{problem}: the ledger is at an older audit-entry contract than this reeve " \
|
|
53
|
+
"writes (#{CONTRACT_VERSION}). Run `bin/rails generate reeve:upgrade` and " \
|
|
54
|
+
"`bin/rails db:migrate`."
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# What a verification established, and — as importantly — what it did not
|
|
7
|
+
# (specs/002 FR-012–FR-014, data-model "Verification result").
|
|
8
|
+
#
|
|
9
|
+
# The status says whether the chain held; the coverage says over what. They are
|
|
10
|
+
# separate fields so that no reader can take one for the other: an intact chain that
|
|
11
|
+
# covers the last 300 rows of a ledger of 5,300 is intact *for those 300*.
|
|
12
|
+
class Verification
|
|
13
|
+
STATUSES = %i[intact broken unverifiable no_chain].freeze
|
|
14
|
+
|
|
15
|
+
# `sequence` is an Integer or a Range (a gap), or nil for a break that has no
|
|
16
|
+
# position; an unchained row has none and is named by `invocation_id` instead.
|
|
17
|
+
# `key_id` is set for unknown_key: the fingerprint of the key the verifier lacked.
|
|
18
|
+
# `epoch` is set for a break about a key epoch's record (specs/003), whose sequence is nil.
|
|
19
|
+
Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, :epoch,
|
|
20
|
+
keyword_init: true)
|
|
21
|
+
|
|
22
|
+
# One key epoch as verification found it (specs/003 FR-013). `entry_count` counts the
|
|
23
|
+
# walked rows that named it; `status` is :verified, :key_not_configured or :altered.
|
|
24
|
+
EpochSummary = Struct.new(:epoch, :key_id, :first_sequence, :entry_count, :status,
|
|
25
|
+
keyword_init: true) do
|
|
26
|
+
def as_json(*)
|
|
27
|
+
{ "epoch" => epoch, "key_id" => key_id, "first_sequence" => first_sequence,
|
|
28
|
+
"entries" => entry_count, "status" => status.to_s }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
EPOCH_STATUSES = %i[verified key_not_configured altered].freeze
|
|
33
|
+
|
|
34
|
+
DEFAULT_LIMIT = 100
|
|
35
|
+
|
|
36
|
+
KINDS = %i[
|
|
37
|
+
digest_mismatch missing duplicate unchained head_mismatch beyond_head
|
|
38
|
+
genesis_altered chain_record_missing unknown_key
|
|
39
|
+
epoch_missing epoch_mismatch epoch_altered
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# The status rules (data-model), for a verification that had a key and found a
|
|
43
|
+
# chain; the no-key and no-chain answers are given before a walk ever starts. Any
|
|
44
|
+
# break but an unknown key is a broken chain. Rows only an absent key could check
|
|
45
|
+
# leave it unverifiable — never intact, and never called tampered.
|
|
46
|
+
def self.status_for(breaks)
|
|
47
|
+
kinds = breaks.map(&:kind)
|
|
48
|
+
return :broken if (kinds - [:unknown_key]).any?
|
|
49
|
+
return :unverifiable if kinds.include?(:unknown_key)
|
|
50
|
+
|
|
51
|
+
:intact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
|
|
55
|
+
:uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
|
|
56
|
+
:reason, :epochs, :before_first_epoch
|
|
57
|
+
|
|
58
|
+
def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
|
|
59
|
+
uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
|
|
60
|
+
unchained_at_genesis: nil, reason: nil, epochs: [], before_first_epoch: nil)
|
|
61
|
+
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
62
|
+
|
|
63
|
+
@status = status
|
|
64
|
+
@covered = covered
|
|
65
|
+
@breaks = breaks.freeze
|
|
66
|
+
@head_at_start = head_at_start
|
|
67
|
+
@head_at_end = head_at_end
|
|
68
|
+
@uncovered_now = uncovered_now
|
|
69
|
+
@uncovered_at_genesis = uncovered_at_genesis
|
|
70
|
+
@unchained_now = unchained_now
|
|
71
|
+
@unchained_at_genesis = unchained_at_genesis
|
|
72
|
+
@reason = reason
|
|
73
|
+
@epochs = epochs.freeze
|
|
74
|
+
@before_first_epoch = before_first_epoch
|
|
75
|
+
freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def intact?
|
|
79
|
+
status == :intact
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Never capped, whatever the display does with the list.
|
|
83
|
+
def totals
|
|
84
|
+
breaks.each_with_object(Hash.new(0)) { |b, counts| counts[b.kind] += 1 }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def unverifiable_count
|
|
88
|
+
totals[:unknown_key]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The report a person reads (contracts/ledger-chain.md "Output"). The first line
|
|
92
|
+
# states the status and what it covers together; no line says "intact" or "pass"
|
|
93
|
+
# without the covered range and the uncovered count beside it (SC-006). `limit`
|
|
94
|
+
# caps the breaks listed, never the totals.
|
|
95
|
+
def to_s(limit: DEFAULT_LIMIT)
|
|
96
|
+
Report.new(self).to_s(limit: limit)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def as_json(*)
|
|
100
|
+
{
|
|
101
|
+
"status" => status.to_s, "covered" => range_json(covered),
|
|
102
|
+
"head_at_start" => head_at_start, "head_at_end" => head_at_end,
|
|
103
|
+
"uncovered_now" => uncovered_now, "uncovered_at_genesis" => uncovered_at_genesis,
|
|
104
|
+
"unchained_now" => unchained_now, "unchained_at_genesis" => unchained_at_genesis,
|
|
105
|
+
"unverifiable_count" => unverifiable_count,
|
|
106
|
+
"totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s
|
|
107
|
+
}.merge(listing_json)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def listing_json
|
|
113
|
+
{ "breaks" => breaks.map { |b| break_json(b) }, "epochs" => epochs.map(&:as_json),
|
|
114
|
+
"before_first_epoch" => before_first_epoch }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def break_json(found)
|
|
118
|
+
{
|
|
119
|
+
"kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
|
|
120
|
+
"detail" => found.detail, "invocation_id" => found.invocation_id,
|
|
121
|
+
"key_id" => found.key_id, "epoch" => found.epoch
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def range_json(value)
|
|
126
|
+
value.is_a?(Range) ? { "first" => value.first, "last" => value.last } : value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|