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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +293 -3
  3. data/README.md +276 -11
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
  6. data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
  7. data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
  10. data/lib/reeve/audit/chain/anchoring.rb +91 -0
  11. data/lib/reeve/audit/chain/canonical.rb +38 -7
  12. data/lib/reeve/audit/chain/checkpoint.rb +228 -0
  13. data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
  14. data/lib/reeve/audit/chain/entrypoints.rb +29 -0
  15. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  16. data/lib/reeve/audit/chain/epochs.rb +83 -0
  17. data/lib/reeve/audit/chain/head.rb +55 -2
  18. data/lib/reeve/audit/chain/keyring.rb +6 -0
  19. data/lib/reeve/audit/chain/report.rb +232 -32
  20. data/lib/reeve/audit/chain/retention/archive.rb +98 -0
  21. data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
  22. data/lib/reeve/audit/chain/retention/check.rb +94 -0
  23. data/lib/reeve/audit/chain/retention/connection.rb +65 -0
  24. data/lib/reeve/audit/chain/retention/digest.rb +29 -0
  25. data/lib/reeve/audit/chain/retention/plan.rb +100 -0
  26. data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
  27. data/lib/reeve/audit/chain/retention/purge.rb +155 -0
  28. data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
  29. data/lib/reeve/audit/chain/retention/records.rb +23 -0
  30. data/lib/reeve/audit/chain/rotation.rb +124 -0
  31. data/lib/reeve/audit/chain/schema.rb +59 -0
  32. data/lib/reeve/audit/chain/verification.rb +53 -10
  33. data/lib/reeve/audit/chain/verifier.rb +75 -22
  34. data/lib/reeve/audit/chain/walk.rb +24 -4
  35. data/lib/reeve/audit/chain.rb +61 -13
  36. data/lib/reeve/audit/chain_epoch.rb +24 -0
  37. data/lib/reeve/audit/chain_record.rb +14 -8
  38. data/lib/reeve/audit/file_anchor.rb +49 -0
  39. data/lib/reeve/audit/retention_record.rb +24 -0
  40. data/lib/reeve/audit.rb +30 -3
  41. data/lib/reeve/configuration.rb +25 -0
  42. data/lib/reeve/errors.rb +15 -0
  43. data/lib/reeve/railtie.rb +2 -1
  44. data/lib/reeve/tasks/descriptions.rb +22 -0
  45. data/lib/reeve/tasks/purge_options.rb +54 -0
  46. data/lib/reeve/tasks.rb +104 -7
  47. data/lib/reeve/testing/checks/contract_version.rb +2 -2
  48. data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
  49. data/lib/reeve/testing/ledger.rb +19 -3
  50. data/lib/reeve/version.rb +1 -1
  51. 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
- # Every row is chained to the one before it with a keyed digest (audit-entry contract 3),
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
- # Also explicitly out of scope for now, so you can plan around it:
30
- # * no retention, rotation or archival — this table grows until you prune it, and
31
- # pruning is your policy, run by a role that is allowed to delete. Deleting rows
32
- # breaks the chain by design.
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 = "config.ledger_key = " \
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 += chain_message if @emitted.any? { |step| step[:contract] == 3 }
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 => %w[
36
- chain_sequence chain_key_id contract_version
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
- TIME_FIELDS = %w[occurred_at genesis_at].freeze
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