reeve 0.6.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 +176 -3
- data/README.md +42 -4
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +23 -1
- data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +10 -25
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +51 -0
- data/lib/reeve/audit/chain/canonical.rb +22 -7
- 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 +29 -1
- data/lib/reeve/audit/chain/report.rb +45 -2
- 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 +28 -7
- data/lib/reeve/audit/chain/verifier.rb +17 -9
- data/lib/reeve/audit/chain/walk.rb +16 -2
- data/lib/reeve/audit/chain.rb +49 -10
- data/lib/reeve/audit/chain_epoch.rb +24 -0
- data/lib/reeve/audit/chain_record.rb +13 -8
- data/lib/reeve/audit.rb +14 -3
- data/lib/reeve/railtie.rb +2 -1
- data/lib/reeve/tasks.rb +29 -0
- data/lib/reeve/testing/checks/contract_version.rb +2 -2
- data/lib/reeve/version.rb +1 -1
- metadata +9 -2
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# The key epochs as verification sees them (specs/003 FR-011): read once, then asked
|
|
7
|
+
# about every row the walk visits. Counts, as it goes, the rows that named each epoch and
|
|
8
|
+
# the rows chained before the first, for the report.
|
|
9
|
+
#
|
|
10
|
+
# A row at contract 4 names its epoch, and must carry that epoch's key and not precede
|
|
11
|
+
# its start. A contract-3 row names none and is checked exactly as 0.6.0 checked it,
|
|
12
|
+
# wherever it sits: one written by a 0.6.0 server after the first epoch opened may carry
|
|
13
|
+
# any key that server had, and a verifier without that key cannot tell it from a
|
|
14
|
+
# rewritten one — calling it tampered would report a rolling deploy as an attack.
|
|
15
|
+
class EpochRules
|
|
16
|
+
attr_reader :records, :counts, :before_first_epoch, :summaries
|
|
17
|
+
|
|
18
|
+
# Every epoch record, in one query whatever the ledger's size (FR-014). A ledger whose
|
|
19
|
+
# gem was upgraded before its table was migrated has none — and no row that could
|
|
20
|
+
# name one, since writes refuse until it is migrated (FR-018).
|
|
21
|
+
def self.read(keyring:, entry_class:)
|
|
22
|
+
present = entry_class.connection_pool.with_connection do |connection|
|
|
23
|
+
connection.table_exists?(EPOCHS_TABLE)
|
|
24
|
+
end
|
|
25
|
+
new(present ? ChainEpoch.uncached { ChainEpoch.all.to_a } : [], keyring: keyring)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `records` are ChainEpoch rows, in any order.
|
|
29
|
+
def initialize(records, keyring:)
|
|
30
|
+
@records = records.sort_by(&:epoch).freeze
|
|
31
|
+
@keyring = keyring
|
|
32
|
+
@by_number = @records.to_h { |record| [record.epoch, record] }
|
|
33
|
+
@first_start = @records.map(&:first_sequence).min
|
|
34
|
+
@counts = Hash.new(0)
|
|
35
|
+
@before_first_epoch = 0
|
|
36
|
+
@summaries = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Checks the records themselves once the walk has counted their rows (EpochCheck);
|
|
40
|
+
# `head` is the chain record as read after the walk. Leaves the result in `summaries`.
|
|
41
|
+
def check!(record, head, report:)
|
|
42
|
+
@summaries = EpochCheck.new(keyring: keyring, report: report)
|
|
43
|
+
.call(self, genesis_digest: record&.genesis_digest, head: head)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# [kind, detail] for a row that contradicts the epoch records, or nil.
|
|
47
|
+
def judge(entry)
|
|
48
|
+
return epoch_bearing(entry) if entry.contract_version.to_i >= EPOCH_CONTRACT
|
|
49
|
+
|
|
50
|
+
@before_first_epoch += 1 if before_first?(entry.chain_sequence)
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :keyring
|
|
57
|
+
|
|
58
|
+
def epoch_bearing(entry)
|
|
59
|
+
number = entry.chain_epoch
|
|
60
|
+
epoch = number && @by_number[number]
|
|
61
|
+
return [:epoch_missing, missing(number)] unless epoch
|
|
62
|
+
|
|
63
|
+
@counts[number] += 1
|
|
64
|
+
if epoch.key_id != entry.chain_key_id
|
|
65
|
+
[:epoch_mismatch,
|
|
66
|
+
"signed by key #{entry.chain_key_id}, but epoch #{number}'s key is #{epoch.key_id}"]
|
|
67
|
+
elsif entry.chain_sequence < epoch.first_sequence
|
|
68
|
+
[:epoch_mismatch,
|
|
69
|
+
"names epoch #{number}, which begins at sequence #{epoch.first_sequence}"]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# True for a row chained before any epoch began — and for every row when none has.
|
|
74
|
+
def before_first?(sequence)
|
|
75
|
+
@first_start.nil? || sequence < @first_start
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def missing(number)
|
|
79
|
+
number.nil? ? "names no key epoch" : "names epoch #{number}, which has no record"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The epoch records themselves (specs/003 FR-010): numbered 0..n, each beginning after
|
|
84
|
+
# the one before (or with it: two rotations with no write between them begin at the same
|
|
85
|
+
# sequence) and no later than one past the head, one key each, and each matching
|
|
86
|
+
# its digest — which links it to the epoch before, or, for epoch 0, to the genesis. A
|
|
87
|
+
# record whose key is not configured cannot be checked, and that is said, never passed
|
|
88
|
+
# over. Returns one Verification::EpochSummary per record; breaks go to `report`, as
|
|
89
|
+
# (kind, nil, detail, nil, key_id, epoch:) — the verifier's own `add`.
|
|
90
|
+
class EpochCheck
|
|
91
|
+
def initialize(keyring:, report:)
|
|
92
|
+
@keyring = keyring
|
|
93
|
+
@report = report
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `head` is the chain record as read after the walk — later than the epochs were
|
|
97
|
+
# read, so an epoch a rotation opened mid-run is never taken for one past the head.
|
|
98
|
+
def call(rules, genesis_digest:, head:)
|
|
99
|
+
previous = { digest: genesis_digest, first_sequence: 0, keys: [] }
|
|
100
|
+
expected = 0
|
|
101
|
+
rules.records.map do |epoch|
|
|
102
|
+
(expected...epoch.epoch).each do |gap|
|
|
103
|
+
report(:epoch_missing, gap, "epoch #{gap} has no record")
|
|
104
|
+
end
|
|
105
|
+
expected = epoch.epoch + 1
|
|
106
|
+
summary(epoch, rules.counts[epoch.epoch], check(epoch, previous, head)).tap do
|
|
107
|
+
previous = { digest: epoch.epoch_digest, first_sequence: epoch.first_sequence,
|
|
108
|
+
keys: previous[:keys] + [epoch.key_id] }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
attr_reader :keyring
|
|
116
|
+
|
|
117
|
+
# :altered, :key_not_configured or :verified, reporting what makes it so.
|
|
118
|
+
def check(epoch, previous, head)
|
|
119
|
+
problem = out_of_place(epoch, previous, head) || digest_problem(epoch, previous)
|
|
120
|
+
return :altered.tap { report(:epoch_altered, epoch.epoch, problem) } if problem
|
|
121
|
+
return :verified if keyring.for(epoch.key_id)
|
|
122
|
+
|
|
123
|
+
report(:unknown_key, epoch.epoch, "epoch #{epoch.epoch} is signed by key #{epoch.key_id}",
|
|
124
|
+
epoch.key_id)
|
|
125
|
+
:key_not_configured
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def report(kind, number, detail, key_id = nil)
|
|
129
|
+
@report.call(kind, nil, detail, nil, key_id, epoch: number)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def out_of_place(epoch, previous, head)
|
|
133
|
+
name = "epoch #{epoch.epoch}"
|
|
134
|
+
if epoch.epoch.positive? && epoch.first_sequence < previous[:first_sequence]
|
|
135
|
+
"#{name} begins at sequence #{epoch.first_sequence}, before the epoch before it"
|
|
136
|
+
elsif head && epoch.first_sequence > head.head_sequence + 1
|
|
137
|
+
"#{name} begins at sequence #{epoch.first_sequence}, past the head"
|
|
138
|
+
elsif previous[:keys].include?(epoch.key_id)
|
|
139
|
+
"#{name} reuses the key of an earlier epoch"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def digest_problem(epoch, previous)
|
|
144
|
+
secret = keyring.for(epoch.key_id)
|
|
145
|
+
return nil if secret.nil? || previous[:digest].nil?
|
|
146
|
+
|
|
147
|
+
expected = Canonical.epoch_digest(secret, epoch, previous_digest: previous[:digest])
|
|
148
|
+
"epoch #{epoch.epoch} does not match its digest" unless expected == epoch.epoch_digest
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def summary(epoch, count, status)
|
|
152
|
+
Verification::EpochSummary.new(epoch: epoch.epoch, key_id: epoch.key_id,
|
|
153
|
+
first_sequence: epoch.first_sequence, entry_count: count,
|
|
154
|
+
status: status)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# The key epochs, as a write sees them (specs/003 FR-001–FR-005).
|
|
7
|
+
#
|
|
8
|
+
# One instance per append or rotation, bound to the connection it runs on and used
|
|
9
|
+
# only under the chain record's lock (Head), so finding a key's epoch and opening the
|
|
10
|
+
# next one are a single step as far as any other writer can tell. The epoch record
|
|
11
|
+
# commits or rolls back with the write that opened it.
|
|
12
|
+
#
|
|
13
|
+
# A key opens one epoch, ever. A process signing with a key whose epoch is not the
|
|
14
|
+
# latest — one still on the old key during a rolling deploy — writes under that
|
|
15
|
+
# earlier epoch: a straggler, legitimate, and verified by its own key.
|
|
16
|
+
class Epochs
|
|
17
|
+
def initialize(connection:)
|
|
18
|
+
@connection = connection
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The number of the epoch `signer`'s key opened, opening the next one at `sequence`
|
|
22
|
+
# if it has none.
|
|
23
|
+
def for_signing!(signer, sequence:)
|
|
24
|
+
number_for(signer.signing_id) || open!(signer, first_sequence: sequence)[:epoch]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def number_for(key_id)
|
|
28
|
+
sql = "SELECT epoch FROM #{EPOCHS_TABLE} WHERE key_id = #{connection.quote(key_id)}"
|
|
29
|
+
value = connection.select_value(current(sql))
|
|
30
|
+
value.nil? ? nil : Integer(value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Every epoch, oldest first, as hashes with symbol keys.
|
|
34
|
+
def all
|
|
35
|
+
sql = "SELECT epoch, key_id, first_sequence FROM #{EPOCHS_TABLE} ORDER BY epoch"
|
|
36
|
+
connection.select_all(current(sql))
|
|
37
|
+
.map do |row|
|
|
38
|
+
{ epoch: Integer(row["epoch"]), key_id: row["key_id"],
|
|
39
|
+
first_sequence: Integer(row["first_sequence"]) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Writes epoch `max + 1` (or 0) for `signer`'s key and returns it. Linked to the
|
|
44
|
+
# latest epoch's digest, or to the genesis for epoch 0, so the chain must have
|
|
45
|
+
# started.
|
|
46
|
+
def open!(signer, first_sequence:)
|
|
47
|
+
sql = "SELECT epoch, epoch_digest FROM #{EPOCHS_TABLE} ORDER BY epoch DESC LIMIT 1"
|
|
48
|
+
latest = connection.select_one(current(sql))
|
|
49
|
+
epoch = { epoch: latest ? Integer(latest["epoch"]) + 1 : 0, key_id: signer.signing_id,
|
|
50
|
+
first_sequence: first_sequence, opened_at: Time.now.utc.floor(6) }
|
|
51
|
+
previous = latest ? latest["epoch_digest"] : genesis_digest
|
|
52
|
+
digest = Canonical.epoch_digest(signer.signing_secret, epoch, previous_digest: previous)
|
|
53
|
+
epoch.merge(epoch_digest: digest).tap { |values| insert(values) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :connection
|
|
59
|
+
|
|
60
|
+
def current(sql)
|
|
61
|
+
Head.current(connection, sql)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def insert(values)
|
|
65
|
+
quoted = values.values.map { |value| connection.quote(value) }
|
|
66
|
+
connection.insert("INSERT INTO #{EPOCHS_TABLE} (#{values.keys.join(', ')}) " \
|
|
67
|
+
"VALUES (#{quoted.join(', ')})")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def genesis_digest
|
|
71
|
+
connection.select_value("SELECT genesis_digest FROM #{TABLE} " \
|
|
72
|
+
"WHERE name = #{connection.quote(ChainRecord::LEDGER)}")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -18,6 +18,17 @@ module Reeve
|
|
|
18
18
|
# engines: SQLite cannot upgrade a read lock while another writer waits, and ignores
|
|
19
19
|
# SELECT ... FOR UPDATE.
|
|
20
20
|
class Head
|
|
21
|
+
LOCKING_READ_ADAPTERS = /mysql|trilogy/i
|
|
22
|
+
|
|
23
|
+
# A read made under the head's lock must see the latest committed rows. PostgreSQL
|
|
24
|
+
# and SQLite read them anyway; InnoDB at REPEATABLE READ serves a plain SELECT from
|
|
25
|
+
# the snapshot of the caller's transaction, which a writer that committed while this
|
|
26
|
+
# one queued for the lock is not in (code review, specs/003). A locking read is
|
|
27
|
+
# current; LOCK IN SHARE MODE is the spelling every MySQL version accepts.
|
|
28
|
+
def self.current(connection, sql)
|
|
29
|
+
connection.adapter_name.match?(LOCKING_READ_ADAPTERS) ? "#{sql} LOCK IN SHARE MODE" : sql
|
|
30
|
+
end
|
|
31
|
+
|
|
21
32
|
def initialize(connection:, entry_class:)
|
|
22
33
|
@connection = connection
|
|
23
34
|
@entry_class = entry_class
|
|
@@ -62,14 +73,31 @@ module Reeve
|
|
|
62
73
|
[Integer(head["head_sequence"]), head["head_digest"]]
|
|
63
74
|
end
|
|
64
75
|
|
|
76
|
+
# Takes the head's lock without consuming a sequence (specs/003 FR-008) and returns the
|
|
77
|
+
# head: a rotation queues at the same point as a write, so the epoch it opens begins
|
|
78
|
+
# exactly one past the last row any writer committed. A write that changes nothing
|
|
79
|
+
# still takes the row lock on PostgreSQL and MySQL and the write lock on SQLite.
|
|
80
|
+
def lock!
|
|
81
|
+
updated = connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence " \
|
|
82
|
+
"WHERE #{named}")
|
|
83
|
+
raise ActiveRecord::StatementInvalid, "the ledger chain record vanished" if updated.zero?
|
|
84
|
+
|
|
85
|
+
sql = "SELECT head_sequence FROM #{TABLE} WHERE #{named}"
|
|
86
|
+
Integer(connection.select_value(Head.current(connection, sql)))
|
|
87
|
+
end
|
|
88
|
+
|
|
65
89
|
def point_at!(digest)
|
|
66
90
|
connection.update("UPDATE #{TABLE} SET head_digest = #{connection.quote(digest)} " \
|
|
67
91
|
"WHERE #{named}")
|
|
68
92
|
end
|
|
69
93
|
|
|
94
|
+
# The connection this append runs on; Epochs uses it so the epoch an append opens
|
|
95
|
+
# commits or rolls back with the append.
|
|
96
|
+
attr_reader :connection
|
|
97
|
+
|
|
70
98
|
private
|
|
71
99
|
|
|
72
|
-
attr_reader :
|
|
100
|
+
attr_reader :entry_class
|
|
73
101
|
|
|
74
102
|
def increment
|
|
75
103
|
connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence + 1 WHERE #{named}")
|
|
@@ -27,10 +27,11 @@ module Reeve
|
|
|
27
27
|
|
|
28
28
|
def to_s(limit:)
|
|
29
29
|
listed = @v.breaks.reject { |found| found.kind == :unknown_key }
|
|
30
|
-
|
|
30
|
+
epochs = EpochLines.new(@v).to_a
|
|
31
|
+
return [summary, *epochs].join("\n") if listed.empty?
|
|
31
32
|
|
|
32
33
|
lines = listed.first(limit).map { |found| line_for(found) }
|
|
33
|
-
[summary, totals, *lines, more(listed, limit)].compact.join("\n")
|
|
34
|
+
[summary, totals, *epochs, *lines, more(listed, limit)].compact.join("\n")
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
private
|
|
@@ -113,6 +114,7 @@ module Reeve
|
|
|
113
114
|
def label(found)
|
|
114
115
|
sequence = found.sequence
|
|
115
116
|
return "invocation #{found.invocation_id}" if found.invocation_id
|
|
117
|
+
return "epoch #{found.epoch}" if sequence.nil? && found.epoch
|
|
116
118
|
return "—" if sequence.nil?
|
|
117
119
|
return "##{range(sequence)}" if sequence.is_a?(Range)
|
|
118
120
|
|
|
@@ -129,6 +131,47 @@ module Reeve
|
|
|
129
131
|
self.class.number(value.to_i)
|
|
130
132
|
end
|
|
131
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
|
|
132
175
|
end
|
|
133
176
|
end
|
|
134
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
|
|
@@ -15,13 +15,28 @@ module Reeve
|
|
|
15
15
|
# `sequence` is an Integer or a Range (a gap), or nil for a break that has no
|
|
16
16
|
# position; an unchained row has none and is named by `invocation_id` instead.
|
|
17
17
|
# `key_id` is set for unknown_key: the fingerprint of the key the verifier lacked.
|
|
18
|
-
|
|
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
|
|
19
33
|
|
|
20
34
|
DEFAULT_LIMIT = 100
|
|
21
35
|
|
|
22
36
|
KINDS = %i[
|
|
23
37
|
digest_mismatch missing duplicate unchained head_mismatch beyond_head
|
|
24
38
|
genesis_altered chain_record_missing unknown_key
|
|
39
|
+
epoch_missing epoch_mismatch epoch_altered
|
|
25
40
|
].freeze
|
|
26
41
|
|
|
27
42
|
# The status rules (data-model), for a verification that had a key and found a
|
|
@@ -38,11 +53,11 @@ module Reeve
|
|
|
38
53
|
|
|
39
54
|
attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
|
|
40
55
|
:uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
|
|
41
|
-
:reason
|
|
56
|
+
:reason, :epochs, :before_first_epoch
|
|
42
57
|
|
|
43
58
|
def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
|
|
44
59
|
uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
|
|
45
|
-
unchained_at_genesis: nil, reason: nil)
|
|
60
|
+
unchained_at_genesis: nil, reason: nil, epochs: [], before_first_epoch: nil)
|
|
46
61
|
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
47
62
|
|
|
48
63
|
@status = status
|
|
@@ -55,6 +70,8 @@ module Reeve
|
|
|
55
70
|
@unchained_now = unchained_now
|
|
56
71
|
@unchained_at_genesis = unchained_at_genesis
|
|
57
72
|
@reason = reason
|
|
73
|
+
@epochs = epochs.freeze
|
|
74
|
+
@before_first_epoch = before_first_epoch
|
|
58
75
|
freeze
|
|
59
76
|
end
|
|
60
77
|
|
|
@@ -86,18 +103,22 @@ module Reeve
|
|
|
86
103
|
"uncovered_now" => uncovered_now, "uncovered_at_genesis" => uncovered_at_genesis,
|
|
87
104
|
"unchained_now" => unchained_now, "unchained_at_genesis" => unchained_at_genesis,
|
|
88
105
|
"unverifiable_count" => unverifiable_count,
|
|
89
|
-
"totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s
|
|
90
|
-
|
|
91
|
-
}
|
|
106
|
+
"totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s
|
|
107
|
+
}.merge(listing_json)
|
|
92
108
|
end
|
|
93
109
|
|
|
94
110
|
private
|
|
95
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
|
+
|
|
96
117
|
def break_json(found)
|
|
97
118
|
{
|
|
98
119
|
"kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
|
|
99
120
|
"detail" => found.detail, "invocation_id" => found.invocation_id,
|
|
100
|
-
"key_id" => found.key_id
|
|
121
|
+
"key_id" => found.key_id, "epoch" => found.epoch
|
|
101
122
|
}
|
|
102
123
|
end
|
|
103
124
|
|