reeve 0.6.0 → 0.8.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 +293 -3
- data/README.md +276 -11
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
- data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -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_retention_to_reeve_audit_entries.rb.tt +37 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
- data/lib/reeve/audit/chain/anchoring.rb +91 -0
- data/lib/reeve/audit/chain/canonical.rb +38 -7
- data/lib/reeve/audit/chain/checkpoint.rb +228 -0
- data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
- data/lib/reeve/audit/chain/entrypoints.rb +29 -0
- data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
- data/lib/reeve/audit/chain/epochs.rb +83 -0
- data/lib/reeve/audit/chain/head.rb +55 -2
- data/lib/reeve/audit/chain/keyring.rb +6 -0
- data/lib/reeve/audit/chain/report.rb +232 -32
- data/lib/reeve/audit/chain/retention/archive.rb +98 -0
- data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
- data/lib/reeve/audit/chain/retention/check.rb +94 -0
- data/lib/reeve/audit/chain/retention/connection.rb +65 -0
- data/lib/reeve/audit/chain/retention/digest.rb +29 -0
- data/lib/reeve/audit/chain/retention/plan.rb +100 -0
- data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
- data/lib/reeve/audit/chain/retention/purge.rb +155 -0
- data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
- data/lib/reeve/audit/chain/retention/records.rb +23 -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 +53 -10
- data/lib/reeve/audit/chain/verifier.rb +75 -22
- data/lib/reeve/audit/chain/walk.rb +24 -4
- data/lib/reeve/audit/chain.rb +61 -13
- data/lib/reeve/audit/chain_epoch.rb +24 -0
- data/lib/reeve/audit/chain_record.rb +14 -8
- data/lib/reeve/audit/file_anchor.rb +49 -0
- data/lib/reeve/audit/retention_record.rb +24 -0
- data/lib/reeve/audit.rb +30 -3
- data/lib/reeve/configuration.rb +25 -0
- data/lib/reeve/errors.rb +15 -0
- data/lib/reeve/railtie.rb +2 -1
- data/lib/reeve/tasks/descriptions.rb +22 -0
- data/lib/reeve/tasks/purge_options.rb +54 -0
- data/lib/reeve/tasks.rb +104 -7
- data/lib/reeve/testing/checks/contract_version.rb +2 -2
- data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
- data/lib/reeve/testing/ledger.rb +19 -3
- data/lib/reeve/version.rb +1 -1
- metadata +28 -2
|
@@ -16,8 +16,21 @@
|
|
|
16
16
|
# -- the chain record: its head moves on every write
|
|
17
17
|
# GRANT INSERT, SELECT, UPDATE ON reeve_audit_chains TO app_role;
|
|
18
18
|
# GRANT USAGE ON SEQUENCE reeve_audit_chains_id_seq TO app_role;
|
|
19
|
+
# -- the key epochs: written once each, never updated
|
|
20
|
+
# GRANT INSERT, SELECT ON reeve_audit_chain_epochs TO app_role;
|
|
21
|
+
# GRANT USAGE ON SEQUENCE reeve_audit_chain_epochs_id_seq TO app_role;
|
|
22
|
+
# -- retention records: the application only reads them
|
|
23
|
+
# GRANT SELECT ON reeve_audit_retentions TO app_role;
|
|
19
24
|
#
|
|
20
|
-
#
|
|
25
|
+
# -- the retention role, used only by `bin/rails reeve:purge_ledger`
|
|
26
|
+
# GRANT SELECT ON reeve_audit_entries, reeve_audit_chains, reeve_audit_chain_epochs,
|
|
27
|
+
# reeve_audit_retentions TO retention_role;
|
|
28
|
+
# GRANT DELETE ON reeve_audit_entries TO retention_role;
|
|
29
|
+
# GRANT INSERT ON reeve_audit_retentions TO retention_role;
|
|
30
|
+
# GRANT USAGE ON SEQUENCE reeve_audit_retentions_id_seq TO retention_role;
|
|
31
|
+
#
|
|
32
|
+
# Every row is chained to the one before it with a keyed digest, and names the key epoch
|
|
33
|
+
# that signed it (audit-entry contract 4),
|
|
21
34
|
# so a row changed, removed or reordered outside reeve is reported by
|
|
22
35
|
# `bin/rails reeve:verify_ledger`. That is tamper-evidence, not tamper-proofing: it
|
|
23
36
|
# detects a change after the fact and prevents nothing — the grant above is what
|
|
@@ -26,10 +39,10 @@
|
|
|
26
39
|
# reeve_audit_chains; that role needs UPDATE there, which is why the chain table is
|
|
27
40
|
# granted separately from the ledger.
|
|
28
41
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
42
|
+
# The table grows until you purge it. Deleting rows by hand breaks the chain by design and
|
|
43
|
+
# is reported as tampering; `bin/rails reeve:purge_ledger` removes the oldest entries up to
|
|
44
|
+
# a checkpoint, archives them, and leaves a signed record in reeve_audit_retentions so the
|
|
45
|
+
# rest still verifies. It runs on the retention role above, never the application's.
|
|
33
46
|
#
|
|
34
47
|
# `invocation_id` is a UUID stored as a string rather than a native `uuid` column,
|
|
35
48
|
# because reeve supports every database ActiveRecord does and only PostgreSQL has the
|
|
@@ -79,6 +92,10 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
|
|
|
79
92
|
t.bigint :chain_sequence
|
|
80
93
|
t.string :chain_key_id, limit: 16
|
|
81
94
|
t.string :chain_digest, limit: 64
|
|
95
|
+
|
|
96
|
+
# The key epoch whose key signed the row (contract 4), inside the digest. Nullable for
|
|
97
|
+
# the same reason as the chain columns, and NULL on an unchained row.
|
|
98
|
+
t.integer :chain_epoch
|
|
82
99
|
end
|
|
83
100
|
|
|
84
101
|
add_index :reeve_audit_entries, :invocation_id, unique: true
|
|
@@ -111,5 +128,35 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
|
|
|
111
128
|
end
|
|
112
129
|
|
|
113
130
|
add_index :reeve_audit_chains, :name, unique: true
|
|
131
|
+
|
|
132
|
+
# One row per key that has signed the ledger (contract 4): written when the key first
|
|
133
|
+
# signs, or by `bin/rails reeve:rotate_ledger_key`, and never updated. A key opens one
|
|
134
|
+
# epoch only, hence the unique key_id.
|
|
135
|
+
create_table :reeve_audit_chain_epochs do |t|
|
|
136
|
+
t.integer :epoch, null: false
|
|
137
|
+
t.string :key_id, null: false, limit: 16
|
|
138
|
+
t.bigint :first_sequence, null: false
|
|
139
|
+
t.datetime :opened_at, null: false, precision: 6
|
|
140
|
+
t.string :epoch_digest, null: false, limit: 64
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
add_index :reeve_audit_chain_epochs, :epoch, unique: true
|
|
144
|
+
add_index :reeve_audit_chain_epochs, :key_id, unique: true
|
|
145
|
+
|
|
146
|
+
create_table :reeve_audit_retentions do |t|
|
|
147
|
+
t.integer :purge_number, null: false
|
|
148
|
+
t.bigint :through_sequence, null: false
|
|
149
|
+
t.string :through_digest, null: false, limit: 64
|
|
150
|
+
t.bigint :previous_through, null: false
|
|
151
|
+
t.bigint :removed_chained, null: false
|
|
152
|
+
t.bigint :removed_pre_chain, null: false
|
|
153
|
+
t.boolean :archived, null: false
|
|
154
|
+
t.string :archive_sha256, limit: 64
|
|
155
|
+
t.datetime :purged_at, null: false, precision: 6
|
|
156
|
+
t.string :key_id, null: false, limit: 16
|
|
157
|
+
t.string :retention_digest, null: false, limit: 64
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
add_index :reeve_audit_retentions, :purge_number, unique: true
|
|
114
161
|
end
|
|
115
162
|
end
|
|
@@ -102,3 +102,13 @@ end
|
|
|
102
102
|
|
|
103
103
|
# The ledger itself is opt-in, so the core stays loadable without ActiveRecord.
|
|
104
104
|
require "reeve/audit"
|
|
105
|
+
|
|
106
|
+
# Checkpoints of the chain, kept where the database's users cannot change them,
|
|
107
|
+
# catch what the chain alone cannot: the newest entries removed with the head
|
|
108
|
+
# rewound, or the whole ledger deleted. Run `bin/rails reeve:checkpoint_ledger`
|
|
109
|
+
# on a schedule; `reeve:verify_ledger` then checks against them. One anchor per
|
|
110
|
+
# environment, on storage outside the database host — a local file protects
|
|
111
|
+
# nothing:
|
|
112
|
+
#
|
|
113
|
+
# Reeve.config.ledger_anchor =
|
|
114
|
+
# Reeve::Audit::FileAnchor.new("/mnt/ledger-anchor/#{Rails.env}.checkpoints")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Audit-entry contract 3 → 4. Generated by `bin/rails generate reeve:upgrade`.
|
|
4
|
+
#
|
|
5
|
+
# Records which key signed which stretch of the chain. Each key that signs the ledger opens
|
|
6
|
+
# a numbered key epoch, recorded once in reeve_audit_chain_epochs, and every row reeve chains
|
|
7
|
+
# from here on names its epoch in `chain_epoch`, inside its digest. Verification then reports
|
|
8
|
+
# a row whose key or epoch was changed as tampering, where before it could only say the row
|
|
9
|
+
# was signed by a key it did not have.
|
|
10
|
+
#
|
|
11
|
+
# Rows already in the table are not touched. Rows chained before the first epoch opens keep
|
|
12
|
+
# exactly the verification they had; the column is nullable for that reason. The first
|
|
13
|
+
# chained write after this migration opens epoch 0 with the configured ledger key.
|
|
14
|
+
#
|
|
15
|
+
# Rotate the key with `bin/rails reeve:rotate_ledger_key` (see the CHANGELOG).
|
|
16
|
+
#
|
|
17
|
+
# The application role needs INSERT and SELECT on the new table, never UPDATE:
|
|
18
|
+
#
|
|
19
|
+
# GRANT INSERT, SELECT ON reeve_audit_chain_epochs TO app_role;
|
|
20
|
+
class AddKeyEpochsToReeveAuditEntries < ActiveRecord::Migration[7.0]
|
|
21
|
+
def change
|
|
22
|
+
add_column :reeve_audit_entries, :chain_epoch, :integer
|
|
23
|
+
|
|
24
|
+
create_table :reeve_audit_chain_epochs do |t|
|
|
25
|
+
t.integer :epoch, null: false
|
|
26
|
+
t.string :key_id, null: false, limit: 16
|
|
27
|
+
t.bigint :first_sequence, null: false
|
|
28
|
+
t.datetime :opened_at, null: false, precision: 6
|
|
29
|
+
t.string :epoch_digest, null: false, limit: 64
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
add_index :reeve_audit_chain_epochs, :epoch, unique: true
|
|
33
|
+
add_index :reeve_audit_chain_epochs, :key_id, unique: true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ledger retention. Generated by `bin/rails generate reeve:upgrade`. The audit-entry contract
|
|
4
|
+
# stays 4: no column of reeve_audit_entries changes.
|
|
5
|
+
#
|
|
6
|
+
# Adds reeve_audit_retentions, where `bin/rails reeve:purge_ledger` records each purge: the
|
|
7
|
+
# checkpoint it purged up to, how many rows it removed, and the archive it wrote, signed with
|
|
8
|
+
# the ledger key. Verification reads it to tell a purge from a deletion. Nothing is purged
|
|
9
|
+
# until you run the task.
|
|
10
|
+
#
|
|
11
|
+
# The application role only reads the new table. The retention role, which only the purge
|
|
12
|
+
# uses, needs DELETE on the ledger and INSERT here:
|
|
13
|
+
#
|
|
14
|
+
# GRANT SELECT ON reeve_audit_retentions TO app_role;
|
|
15
|
+
# GRANT SELECT ON reeve_audit_entries, reeve_audit_chains, reeve_audit_chain_epochs,
|
|
16
|
+
# reeve_audit_retentions TO retention_role;
|
|
17
|
+
# GRANT DELETE ON reeve_audit_entries TO retention_role;
|
|
18
|
+
# GRANT INSERT ON reeve_audit_retentions TO retention_role;
|
|
19
|
+
class AddLedgerRetentionToReeveAuditEntries < ActiveRecord::Migration[7.0]
|
|
20
|
+
def change
|
|
21
|
+
create_table :reeve_audit_retentions do |t|
|
|
22
|
+
t.integer :purge_number, null: false
|
|
23
|
+
t.bigint :through_sequence, null: false
|
|
24
|
+
t.string :through_digest, null: false, limit: 64
|
|
25
|
+
t.bigint :previous_through, null: false
|
|
26
|
+
t.bigint :removed_chained, null: false
|
|
27
|
+
t.bigint :removed_pre_chain, null: false
|
|
28
|
+
t.boolean :archived, null: false
|
|
29
|
+
t.string :archive_sha256, limit: 64
|
|
30
|
+
t.datetime :purged_at, null: false, precision: 6
|
|
31
|
+
t.string :key_id, null: false, limit: 16
|
|
32
|
+
t.string :retention_digest, null: false, limit: 64
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
add_index :reeve_audit_retentions, :purge_number, unique: true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
4
|
require "rails/generators/active_record"
|
|
5
|
+
require_relative "upgrade_notes"
|
|
5
6
|
|
|
6
7
|
module Reeve
|
|
7
8
|
module Generators
|
|
@@ -50,11 +51,26 @@ module Reeve
|
|
|
50
51
|
creates: %w[reeve_audit_chains],
|
|
51
52
|
template: "add_ledger_chain_to_reeve_audit_entries.rb.tt",
|
|
52
53
|
destination: "add_ledger_chain_to_reeve_audit_entries.rb"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
contract: 4,
|
|
57
|
+
adds: %w[chain_epoch],
|
|
58
|
+
creates: %w[reeve_audit_chain_epochs],
|
|
59
|
+
template: "add_key_epochs_to_reeve_audit_entries.rb.tt",
|
|
60
|
+
destination: "add_key_epochs_to_reeve_audit_entries.rb"
|
|
61
|
+
},
|
|
62
|
+
# Retention (specs/005) adds a table and no column, so the contract stays 4.
|
|
63
|
+
{
|
|
64
|
+
contract: 4,
|
|
65
|
+
note: :retention,
|
|
66
|
+
adds: [],
|
|
67
|
+
creates: %w[reeve_audit_retentions],
|
|
68
|
+
template: "add_ledger_retention_to_reeve_audit_entries.rb.tt",
|
|
69
|
+
destination: "add_ledger_retention_to_reeve_audit_entries.rb"
|
|
53
70
|
}
|
|
54
71
|
].freeze
|
|
55
72
|
|
|
56
|
-
KEY_LINE =
|
|
57
|
-
'Rails.application.key_generator.generate_key("reeve audit ledger", 32)'
|
|
73
|
+
KEY_LINE = UpgradeNotes::KEY_LINE
|
|
58
74
|
|
|
59
75
|
source_root File.expand_path("templates", __dir__)
|
|
60
76
|
|
|
@@ -117,29 +133,7 @@ module Reeve
|
|
|
117
133
|
Existing rows are stamped with the contract they were actually written under,
|
|
118
134
|
not with the current one.
|
|
119
135
|
NEXT
|
|
120
|
-
message
|
|
121
|
-
message
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# Contract 3 changes what the application must configure, not only the table: without
|
|
125
|
-
# a key, every guarded call fails closed once the migration runs. The host is reading
|
|
126
|
-
# this output at exactly the moment they need to know that.
|
|
127
|
-
def chain_message
|
|
128
|
-
<<~CHAIN
|
|
129
|
-
|
|
130
|
-
The ledger is now chained (contract 3). Add this line to config/initializers/reeve.rb
|
|
131
|
-
before you deploy, or every guarded call fails closed:
|
|
132
|
-
|
|
133
|
-
#{KEY_LINE}
|
|
134
|
-
|
|
135
|
-
It derives the key from secret_key_base, so rotating secret_key_base rotates the
|
|
136
|
-
ledger key: keep the old derived key in config.retired_ledger_keys, or entries it
|
|
137
|
-
signed become unverifiable. A dedicated key avoids that coupling.
|
|
138
|
-
|
|
139
|
-
Very large ledger on PostgreSQL? The migration builds a unique index that blocks
|
|
140
|
-
writes while it scans; the migration's header shows how to build it with
|
|
141
|
-
algorithm: :concurrently instead.
|
|
142
|
-
CHAIN
|
|
136
|
+
message + UpgradeNotes.for(@emitted)
|
|
143
137
|
end
|
|
144
138
|
|
|
145
139
|
# Asked of the table rather than of a version the host recorded: the table is the
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Generators
|
|
5
|
+
# What `reeve:upgrade` tells the host after writing a step whose contract changes more
|
|
6
|
+
# than the table. The host is reading this output at exactly the moment they need it.
|
|
7
|
+
module UpgradeNotes
|
|
8
|
+
KEY_LINE = "config.ledger_key = " \
|
|
9
|
+
'Rails.application.key_generator.generate_key("reeve audit ledger", 32)'
|
|
10
|
+
|
|
11
|
+
# Contract 3 changes what the application must configure, not only the table: without
|
|
12
|
+
# a key, every guarded call fails closed once the migration runs.
|
|
13
|
+
CHAIN = <<~CHAIN.freeze
|
|
14
|
+
|
|
15
|
+
The ledger is now chained (contract 3). Add this line to config/initializers/reeve.rb
|
|
16
|
+
before you deploy, or every guarded call fails closed:
|
|
17
|
+
|
|
18
|
+
#{KEY_LINE}
|
|
19
|
+
|
|
20
|
+
It derives the key from secret_key_base, so rotating secret_key_base rotates the
|
|
21
|
+
ledger key: keep the old derived key in config.retired_ledger_keys, or entries it
|
|
22
|
+
signed become unverifiable. A dedicated key avoids that coupling.
|
|
23
|
+
|
|
24
|
+
Very large ledger on PostgreSQL? The migration builds a unique index that blocks
|
|
25
|
+
writes while it scans; the migration's header shows how to build it with
|
|
26
|
+
algorithm: :concurrently instead.
|
|
27
|
+
CHAIN
|
|
28
|
+
|
|
29
|
+
# Contract 4 changes how a rotation is done, so the host learns the command here.
|
|
30
|
+
EPOCHS = <<~EPOCHS
|
|
31
|
+
|
|
32
|
+
The ledger now records key epochs (contract 4): the first chained write opens
|
|
33
|
+
epoch 0 with the configured ledger key. To rotate the key later, set the new key
|
|
34
|
+
as config.ledger_key, keep the old one in config.retired_ledger_keys, deploy, and run:
|
|
35
|
+
|
|
36
|
+
bin/rails reeve:rotate_ledger_key
|
|
37
|
+
|
|
38
|
+
The application role needs INSERT and SELECT on reeve_audit_chain_epochs.
|
|
39
|
+
EPOCHS
|
|
40
|
+
|
|
41
|
+
# Retention changes nothing until the host runs a purge, but the purge needs a role the
|
|
42
|
+
# application does not have, and the host should hear that before it needs one.
|
|
43
|
+
RETENTION = <<~RETENTION
|
|
44
|
+
|
|
45
|
+
The ledger can now be purged (reeve_audit_retentions): bin/rails reeve:purge_ledger
|
|
46
|
+
removes the oldest entries up to a checkpoint and records the purge, so the rest still
|
|
47
|
+
verifies. Nothing is purged until you run it. The application role needs SELECT on
|
|
48
|
+
the new table; the purge runs on a retention role of its own (see the migration).
|
|
49
|
+
RETENTION
|
|
50
|
+
|
|
51
|
+
# Keyed by the step's note, or by its contract when it has none.
|
|
52
|
+
BY_STEP = { 3 => CHAIN, 4 => EPOCHS, retention: RETENTION }.freeze
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# The notes for the steps just written, oldest first.
|
|
57
|
+
def for(steps)
|
|
58
|
+
steps.filter_map { |step| BY_STEP[step.fetch(:note, step[:contract])] }.join
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# The verifier's checkpoints (specs/004): what it checks the ledger against, and the one,
|
|
7
|
+
# if any, it walks from instead of the genesis.
|
|
8
|
+
#
|
|
9
|
+
# Checkpoints come from outside the database (FR-010). With none, the verifier is exactly
|
|
10
|
+
# 0.7.0's and its report says the run was not anchored.
|
|
11
|
+
class Anchoring
|
|
12
|
+
attr_reader :checkpoints, :from
|
|
13
|
+
|
|
14
|
+
# `from` is :latest, a Checkpoint, or nil to walk from the genesis (Checkpoint.start).
|
|
15
|
+
def initialize(entry_class:, keyring:, checkpoints:, from: nil)
|
|
16
|
+
@entry_class = entry_class
|
|
17
|
+
@keyring = keyring
|
|
18
|
+
@checkpoints = checkpoints
|
|
19
|
+
@from = Checkpoint.start(from, checkpoints)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def any?
|
|
23
|
+
checkpoints.any?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The latest retention record that holds (specs/005), or nil: the walk never starts
|
|
27
|
+
# below it, and a `from` at or below it is moot.
|
|
28
|
+
attr_reader :floor
|
|
29
|
+
|
|
30
|
+
def floor=(record)
|
|
31
|
+
@floor = record
|
|
32
|
+
@from = nil if record && from && from.head_sequence <= record.through_sequence
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Where the walk starts, and the digest its first row links to.
|
|
36
|
+
def start
|
|
37
|
+
return from.head_sequence if from
|
|
38
|
+
return floor.through_sequence if floor
|
|
39
|
+
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def link(record)
|
|
44
|
+
return from.head_digest if from
|
|
45
|
+
return floor.through_digest if floor
|
|
46
|
+
|
|
47
|
+
record&.genesis_digest
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Every checkpoint, after the walk, against the head read after it. The checked
|
|
51
|
+
# checkpoint with the highest sequence anchors the run (data-model).
|
|
52
|
+
def check(record, head, epoch_records, report:)
|
|
53
|
+
results = check_with(report).call(checkpoints, record: record, head: head,
|
|
54
|
+
epochs: epoch_records, floor: floor)
|
|
55
|
+
anchor = results.select { |found| found.status == :checked }.max_by(&:head_sequence)
|
|
56
|
+
beyond = anchor && record && [record.head_sequence - anchor.head_sequence, 0].max
|
|
57
|
+
{ checkpoints: results, anchored_at: anchor, beyond_anchor: beyond }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A ledger with no chain, which every checkpoint saw reach an entry: emptied, not never
|
|
61
|
+
# chained (FR-013).
|
|
62
|
+
def emptied(record, report:)
|
|
63
|
+
check_with(report).call(checkpoints, record: record, head: record, epochs: [],
|
|
64
|
+
chained: false)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The starting checkpoint, checked alone before the walk relies on it (FR-015). Returns
|
|
68
|
+
# its results when it does not hold, reporting its breaks; nil when it holds, so its
|
|
69
|
+
# breaks are left to the full check after the walk.
|
|
70
|
+
def failed_start(record, epoch_records, report:)
|
|
71
|
+
return nil if from.nil?
|
|
72
|
+
|
|
73
|
+
found = []
|
|
74
|
+
collect = ->(*args, **options) { found << [args, options] }
|
|
75
|
+
results = check_with(collect).call([from], record: record, head: record,
|
|
76
|
+
epochs: epoch_records, floor: floor)
|
|
77
|
+
return nil if results.first.status == :checked
|
|
78
|
+
|
|
79
|
+
found.each { |args, options| report.call(*args, **options) }
|
|
80
|
+
results
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def check_with(report)
|
|
86
|
+
CheckpointCheck.new(entry_class: @entry_class, keyring: @keyring, report: report)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -31,19 +31,36 @@ module Reeve
|
|
|
31
31
|
ENTRY_TAG = "reeve-ledger-entry-v1"
|
|
32
32
|
GENESIS_TAG = "reeve-ledger-genesis-v1"
|
|
33
33
|
|
|
34
|
+
EPOCH_TAG = "reeve-ledger-epoch-v1"
|
|
35
|
+
|
|
36
|
+
CHECKPOINT_TAG = "reeve-ledger-checkpoint-v1"
|
|
37
|
+
|
|
38
|
+
CONTRACT_3_FIELDS = %w[
|
|
39
|
+
chain_sequence chain_key_id contract_version
|
|
40
|
+
invocation_id occurred_at agent_id agent_name principal_type principal_id
|
|
41
|
+
tool_name arguments outcome rule detail record_type record_ids record_count
|
|
42
|
+
truncated derived guard duration_ms metadata
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# Contract 4 (specs/003) adds the key epoch, next to the key it names.
|
|
34
46
|
ENTRY_FIELDS = {
|
|
35
|
-
3 =>
|
|
36
|
-
|
|
37
|
-
invocation_id occurred_at agent_id agent_name principal_type principal_id
|
|
38
|
-
tool_name arguments outcome rule detail record_type record_ids record_count
|
|
39
|
-
truncated derived guard duration_ms metadata
|
|
40
|
-
].freeze
|
|
47
|
+
3 => CONTRACT_3_FIELDS,
|
|
48
|
+
4 => CONTRACT_3_FIELDS.dup.insert(2, "chain_epoch").freeze
|
|
41
49
|
}.freeze
|
|
42
50
|
|
|
43
51
|
GENESIS_FIELDS = %w[genesis_at pre_chain_count pre_chain_unchained_count
|
|
44
52
|
genesis_key_id].freeze
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
EPOCH_FIELDS = %w[epoch key_id first_sequence opened_at].freeze
|
|
55
|
+
|
|
56
|
+
# A checkpoint's signed fields (specs/004 contracts/checkpoint-v1.md), in this order.
|
|
57
|
+
CHECKPOINT_FIELDS = %w[
|
|
58
|
+
format ledger genesis_digest pre_chain_count pre_chain_unchained_count
|
|
59
|
+
head_sequence head_digest epoch epoch_digest key_id made_at reeve_version
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# `purged_at` is a retention record's (Retention::Digest).
|
|
63
|
+
TIME_FIELDS = %w[occurred_at genesis_at opened_at purged_at].freeze
|
|
47
64
|
|
|
48
65
|
module_function
|
|
49
66
|
|
|
@@ -56,6 +73,20 @@ module Reeve
|
|
|
56
73
|
hmac(secret, GENESIS_TAG, dump(pairs))
|
|
57
74
|
end
|
|
58
75
|
|
|
76
|
+
# An epoch record (specs/003 FR-002), linked to the epoch before it — or, for epoch 0,
|
|
77
|
+
# to the genesis — so the epochs are a chain of their own.
|
|
78
|
+
def epoch_digest(secret, epoch, previous_digest:)
|
|
79
|
+
pairs = EPOCH_FIELDS.map { |field| [field, value(epoch, field)] }
|
|
80
|
+
hmac(secret, EPOCH_TAG, dump([["previous_digest", previous_digest], *pairs]))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A checkpoint (specs/004). `made_at` is signed as the string it is stored as, so a line
|
|
84
|
+
# verifies exactly as written, whatever its reader does with times.
|
|
85
|
+
def checkpoint_signature(secret, values)
|
|
86
|
+
pairs = CHECKPOINT_FIELDS.map { |field| [field, values.fetch(field)] }
|
|
87
|
+
hmac(secret, CHECKPOINT_TAG, dump(pairs))
|
|
88
|
+
end
|
|
89
|
+
|
|
59
90
|
def entry_message_body(row, previous_digest)
|
|
60
91
|
version = value(row, "contract_version")
|
|
61
92
|
fields = ENTRY_FIELDS.fetch(version) do
|