provance 0.1.0__py3-none-any.whl
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.
- provance/__init__.py +103 -0
- provance/_writer.py +249 -0
- provance/checkpoints.py +101 -0
- provance/cli.py +172 -0
- provance/errors.py +33 -0
- provance/format.py +130 -0
- provance/http.py +339 -0
- provance/ledger.py +176 -0
- provance/models.py +167 -0
- provance/options.py +92 -0
- provance/payload.py +102 -0
- provance/py.typed +0 -0
- provance/signing.py +114 -0
- provance/stores.py +569 -0
- provance/verifier.py +203 -0
- provance-0.1.0.dist-info/METADATA +304 -0
- provance-0.1.0.dist-info/RECORD +20 -0
- provance-0.1.0.dist-info/WHEEL +4 -0
- provance-0.1.0.dist-info/entry_points.txt +2 -0
- provance-0.1.0.dist-info/licenses/LICENSE +21 -0
provance/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""PROVANCE for Python: tamper-evident audit ledgers.
|
|
2
|
+
|
|
3
|
+
Write audit events into a hash chained, signed ledger, and verify ledgers written by any PROVANCE implementation
|
|
4
|
+
(.NET, Java, Python). See docs/SPEC.md for the format.
|
|
5
|
+
|
|
6
|
+
from provance import AuditedPayload, ProvanceLedger, SqlLedgerStore
|
|
7
|
+
|
|
8
|
+
ledger = ProvanceLedger(ledger_id="orders-api-prod", secret_key=os.environ["PROVANCE_SECRET_KEY"],
|
|
9
|
+
store=SqlLedgerStore.from_url("postgresql://app@db/audit"))
|
|
10
|
+
ledger.add_entry("ORDER_REFUNDED", AuditedPayload(actor_id="user-42", description="Refund issued"))
|
|
11
|
+
result = ledger.verify()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
from .checkpoints import FileCheckpointSink, LedgerCheckpointSink, read_latest_checkpoint
|
|
17
|
+
from .errors import (
|
|
18
|
+
LedgerConcurrencyError,
|
|
19
|
+
LedgerError,
|
|
20
|
+
LedgerOverloadedError,
|
|
21
|
+
LedgerStoreError,
|
|
22
|
+
LedgerUnavailableError,
|
|
23
|
+
PayloadError,
|
|
24
|
+
)
|
|
25
|
+
from .format import (
|
|
26
|
+
FORMAT_VERSION,
|
|
27
|
+
canonical_checkpoint,
|
|
28
|
+
canonical_envelope,
|
|
29
|
+
compute_checkpoint_digest,
|
|
30
|
+
compute_entry_hash,
|
|
31
|
+
derive_genesis_hash,
|
|
32
|
+
format_timestamp,
|
|
33
|
+
parse_timestamp,
|
|
34
|
+
sha256_hex,
|
|
35
|
+
)
|
|
36
|
+
from .ledger import ProvanceLedger
|
|
37
|
+
from .models import AuditedPayload, LedgerCheckpoint, LedgerEntry
|
|
38
|
+
from .options import ProvanceOptions, QueueFullBehavior
|
|
39
|
+
from .payload import canonical_payload
|
|
40
|
+
from .signing import HmacLedgerSigner, HmacVerifier, LedgerSigner, SignatureCheck, SignatureVerifier
|
|
41
|
+
from .stores import (
|
|
42
|
+
InMemoryLedgerStore,
|
|
43
|
+
JsonLinesReader,
|
|
44
|
+
LedgerStore,
|
|
45
|
+
ListSource,
|
|
46
|
+
SqlLedgerReader,
|
|
47
|
+
SqlLedgerStore,
|
|
48
|
+
connect,
|
|
49
|
+
connection_factory,
|
|
50
|
+
write_json_lines,
|
|
51
|
+
)
|
|
52
|
+
from .verifier import LedgerTamperedError, LedgerVerifier, VerificationFailure, VerificationResult
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
__version__ = version("provance")
|
|
56
|
+
except PackageNotFoundError: # running from a source checkout
|
|
57
|
+
__version__ = "0.0.0"
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"FORMAT_VERSION",
|
|
61
|
+
"AuditedPayload",
|
|
62
|
+
"FileCheckpointSink",
|
|
63
|
+
"HmacLedgerSigner",
|
|
64
|
+
"HmacVerifier",
|
|
65
|
+
"InMemoryLedgerStore",
|
|
66
|
+
"JsonLinesReader",
|
|
67
|
+
"LedgerCheckpoint",
|
|
68
|
+
"LedgerCheckpointSink",
|
|
69
|
+
"LedgerConcurrencyError",
|
|
70
|
+
"LedgerEntry",
|
|
71
|
+
"LedgerError",
|
|
72
|
+
"LedgerOverloadedError",
|
|
73
|
+
"LedgerSigner",
|
|
74
|
+
"LedgerStore",
|
|
75
|
+
"LedgerStoreError",
|
|
76
|
+
"LedgerTamperedError",
|
|
77
|
+
"LedgerUnavailableError",
|
|
78
|
+
"LedgerVerifier",
|
|
79
|
+
"ListSource",
|
|
80
|
+
"PayloadError",
|
|
81
|
+
"ProvanceLedger",
|
|
82
|
+
"ProvanceOptions",
|
|
83
|
+
"QueueFullBehavior",
|
|
84
|
+
"SignatureCheck",
|
|
85
|
+
"SignatureVerifier",
|
|
86
|
+
"SqlLedgerReader",
|
|
87
|
+
"SqlLedgerStore",
|
|
88
|
+
"VerificationFailure",
|
|
89
|
+
"VerificationResult",
|
|
90
|
+
"canonical_checkpoint",
|
|
91
|
+
"canonical_envelope",
|
|
92
|
+
"canonical_payload",
|
|
93
|
+
"compute_checkpoint_digest",
|
|
94
|
+
"compute_entry_hash",
|
|
95
|
+
"connect",
|
|
96
|
+
"connection_factory",
|
|
97
|
+
"derive_genesis_hash",
|
|
98
|
+
"format_timestamp",
|
|
99
|
+
"parse_timestamp",
|
|
100
|
+
"read_latest_checkpoint",
|
|
101
|
+
"sha256_hex",
|
|
102
|
+
"write_json_lines",
|
|
103
|
+
]
|
provance/_writer.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Background writer of one process. Same algorithm as the .NET and Java writers.
|
|
2
|
+
|
|
3
|
+
Linearity across processes comes from the store: it rejects a sequence or id that already exists. On any failure
|
|
4
|
+
the writer reloads the head from the store, acknowledges the entries that were in fact persisted (looked up by
|
|
5
|
+
their fixed id) and reseals the others on top of the real head. Several application instances can therefore
|
|
6
|
+
write to the same ledger.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import queue
|
|
13
|
+
import random
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from concurrent.futures import Future
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
from .errors import LedgerConcurrencyError, LedgerOverloadedError, LedgerUnavailableError
|
|
23
|
+
from .format import FORMAT_VERSION, MAX_SEQUENCE, compute_entry_hash, normalize_timestamp
|
|
24
|
+
from .models import LedgerEntry
|
|
25
|
+
from .options import ProvanceOptions, QueueFullBehavior
|
|
26
|
+
from .signing import LedgerSigner
|
|
27
|
+
from .stores import LedgerStore
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("provance.writer")
|
|
30
|
+
|
|
31
|
+
_POLL_SECONDS = 0.1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PendingEntry:
|
|
36
|
+
"""An entry prepared by a producer (payload canonicalized and hashed) waiting for the writer.
|
|
37
|
+
|
|
38
|
+
The id is fixed here so that the writer can recognize the entry in the store after an ambiguous failure.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
event_type: str
|
|
42
|
+
payload_json: str
|
|
43
|
+
payload_hash: str
|
|
44
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
45
|
+
future: Future = field(default_factory=Future)
|
|
46
|
+
|
|
47
|
+
def succeed(self, entry: LedgerEntry) -> None:
|
|
48
|
+
if not self.future.done():
|
|
49
|
+
self.future.set_result(entry)
|
|
50
|
+
|
|
51
|
+
def fail(self, error: BaseException) -> None:
|
|
52
|
+
if not self.future.done():
|
|
53
|
+
self.future.set_exception(error)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Aborted(Exception):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LedgerWriter:
|
|
61
|
+
def __init__(self, store: LedgerStore, signer: LedgerSigner, options: ProvanceOptions,
|
|
62
|
+
clock: Callable[[], datetime]):
|
|
63
|
+
self._queue: queue.Queue[PendingEntry] = queue.Queue(maxsize=options.queue_capacity)
|
|
64
|
+
self._store = store
|
|
65
|
+
self._signer = signer
|
|
66
|
+
self._options = options
|
|
67
|
+
self._clock = clock
|
|
68
|
+
self._genesis = options.effective_genesis_hash()
|
|
69
|
+
self._accepting = True
|
|
70
|
+
self._aborted = False
|
|
71
|
+
self._head: tuple[int, str] | None = None # writer thread only
|
|
72
|
+
self.thread = threading.Thread(target=self._run, name=f"provance-writer-{options.ledger_id}", daemon=True)
|
|
73
|
+
|
|
74
|
+
def start(self) -> None:
|
|
75
|
+
self.thread.start()
|
|
76
|
+
|
|
77
|
+
def enqueue(self, entry: PendingEntry) -> None:
|
|
78
|
+
if not self._accepting:
|
|
79
|
+
raise _stopped()
|
|
80
|
+
if self._options.queue_full_behavior is QueueFullBehavior.FAIL_FAST:
|
|
81
|
+
try:
|
|
82
|
+
self._queue.put_nowait(entry)
|
|
83
|
+
except queue.Full:
|
|
84
|
+
raise (LedgerOverloadedError("PROVANCE queue is full.") if self._accepting else _stopped()) from None
|
|
85
|
+
return
|
|
86
|
+
while True:
|
|
87
|
+
try:
|
|
88
|
+
self._queue.put(entry, timeout=_POLL_SECONDS)
|
|
89
|
+
return
|
|
90
|
+
except queue.Full:
|
|
91
|
+
if not self._accepting:
|
|
92
|
+
raise _stopped() from None
|
|
93
|
+
|
|
94
|
+
def stop_accepting(self) -> None:
|
|
95
|
+
self._accepting = False
|
|
96
|
+
|
|
97
|
+
def abort(self) -> None:
|
|
98
|
+
self._accepting = False
|
|
99
|
+
self._aborted = True
|
|
100
|
+
|
|
101
|
+
def fail_queued(self) -> None:
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
self._queue.get_nowait().fail(LedgerUnavailableError("PROVANCE writer stopped before the entry was written."))
|
|
105
|
+
except queue.Empty:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
def _run(self) -> None:
|
|
109
|
+
log.info("PROVANCE writer started for ledger %s.", self._options.ledger_id)
|
|
110
|
+
try:
|
|
111
|
+
while not self._aborted:
|
|
112
|
+
try:
|
|
113
|
+
first = self._queue.get(timeout=_POLL_SECONDS)
|
|
114
|
+
except queue.Empty:
|
|
115
|
+
if not self._accepting and self._queue.empty():
|
|
116
|
+
break
|
|
117
|
+
continue
|
|
118
|
+
batch = [first]
|
|
119
|
+
while len(batch) < self._options.max_batch_size:
|
|
120
|
+
try:
|
|
121
|
+
batch.append(self._queue.get_nowait())
|
|
122
|
+
except queue.Empty:
|
|
123
|
+
break
|
|
124
|
+
try:
|
|
125
|
+
self._process(batch)
|
|
126
|
+
except _Aborted:
|
|
127
|
+
for item in batch:
|
|
128
|
+
item.fail(LedgerUnavailableError("PROVANCE writer stopped before the entry was written."))
|
|
129
|
+
break
|
|
130
|
+
except Exception as e: # never let the writer die
|
|
131
|
+
log.exception("Unexpected error in the PROVANCE writer. The batch is failed and the writer continues.")
|
|
132
|
+
self._head = None
|
|
133
|
+
for item in batch:
|
|
134
|
+
item.fail(LedgerUnavailableError(f"Unexpected PROVANCE writer error: {e}"))
|
|
135
|
+
finally:
|
|
136
|
+
self._accepting = False
|
|
137
|
+
self.fail_queued()
|
|
138
|
+
log.info("PROVANCE writer stopped for ledger %s.", self._options.ledger_id)
|
|
139
|
+
|
|
140
|
+
def _process(self, batch: list[PendingEntry]) -> None:
|
|
141
|
+
pending = list(batch)
|
|
142
|
+
transient_failures = conflicts = 0
|
|
143
|
+
|
|
144
|
+
while pending:
|
|
145
|
+
if self._aborted:
|
|
146
|
+
raise _Aborted()
|
|
147
|
+
sealed: list[LedgerEntry] | None = None
|
|
148
|
+
try:
|
|
149
|
+
head = self._head if self._head is not None else self._load_head()
|
|
150
|
+
sealed = self._seal(pending, head)
|
|
151
|
+
self._store.append(sealed)
|
|
152
|
+
for item, entry in zip(pending, sealed):
|
|
153
|
+
item.succeed(entry)
|
|
154
|
+
self._head = (sealed[-1].sequence, sealed[-1].hash)
|
|
155
|
+
return
|
|
156
|
+
except LedgerConcurrencyError as e:
|
|
157
|
+
failure: Exception = e
|
|
158
|
+
is_conflict = True
|
|
159
|
+
except Exception as e:
|
|
160
|
+
failure = e
|
|
161
|
+
is_conflict = False
|
|
162
|
+
|
|
163
|
+
if self._aborted:
|
|
164
|
+
raise _Aborted()
|
|
165
|
+
|
|
166
|
+
# Whatever happened, our view of the head is no longer trustworthy.
|
|
167
|
+
self._head = None
|
|
168
|
+
|
|
169
|
+
if sealed is not None:
|
|
170
|
+
self._acknowledge_persisted(pending)
|
|
171
|
+
if not pending:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if is_conflict:
|
|
175
|
+
conflicts += 1
|
|
176
|
+
if conflicts > self._options.max_conflict_retries:
|
|
177
|
+
log.error("PROVANCE writer gave up after %d sequence conflicts.", conflicts)
|
|
178
|
+
error = LedgerUnavailableError(f"Gave up after {conflicts} sequence conflicts with other writers.")
|
|
179
|
+
error.__cause__ = failure
|
|
180
|
+
for item in pending:
|
|
181
|
+
item.fail(error)
|
|
182
|
+
return
|
|
183
|
+
log.debug("Sequence conflict (another writer appended first). Resealing %d entries.", len(pending))
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
transient_failures += 1
|
|
187
|
+
if transient_failures >= self._options.max_append_attempts:
|
|
188
|
+
log.error("PROVANCE append failed %d times; failing %d entries: %s", transient_failures, len(pending), failure)
|
|
189
|
+
error = LedgerUnavailableError(f"The ledger store failed {transient_failures} times: {failure}")
|
|
190
|
+
error.__cause__ = failure
|
|
191
|
+
for item in pending:
|
|
192
|
+
item.fail(error)
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
delay = self._backoff(transient_failures)
|
|
196
|
+
log.warning("PROVANCE append failed (attempt %d/%d), retrying in %.0f ms: %s",
|
|
197
|
+
transient_failures, self._options.max_append_attempts, delay * 1000, failure)
|
|
198
|
+
time.sleep(delay)
|
|
199
|
+
|
|
200
|
+
def _acknowledge_persisted(self, pending: list[PendingEntry]) -> None:
|
|
201
|
+
acknowledged = 0
|
|
202
|
+
try:
|
|
203
|
+
for item in pending:
|
|
204
|
+
stored = self._store.get_by_id(item.id)
|
|
205
|
+
if stored is None:
|
|
206
|
+
break
|
|
207
|
+
item.succeed(stored)
|
|
208
|
+
acknowledged += 1
|
|
209
|
+
except Exception as e:
|
|
210
|
+
# If an entry was persisted after all, the next append reuses its id, the store reports a conflict and
|
|
211
|
+
# this method runs again.
|
|
212
|
+
log.warning("Could not check which entries were persisted after a failed append: %s", e)
|
|
213
|
+
if acknowledged:
|
|
214
|
+
log.info("%d entries were persisted despite the reported failure.", acknowledged)
|
|
215
|
+
del pending[:acknowledged]
|
|
216
|
+
|
|
217
|
+
def _load_head(self) -> tuple[int, str]:
|
|
218
|
+
head = self._store.get_head()
|
|
219
|
+
if head is None:
|
|
220
|
+
return 0, self._genesis
|
|
221
|
+
if head.ledger_id != self._options.ledger_id:
|
|
222
|
+
raise RuntimeError(f"The store contains ledger '{head.ledger_id}' but this application is configured for "
|
|
223
|
+
f"'{self._options.ledger_id}'. Use one table per ledger.")
|
|
224
|
+
return head.sequence, head.hash
|
|
225
|
+
|
|
226
|
+
def _seal(self, pending: list[PendingEntry], head: tuple[int, str]) -> list[LedgerEntry]:
|
|
227
|
+
sequence, previous = head
|
|
228
|
+
timestamp = normalize_timestamp(self._clock())
|
|
229
|
+
key_id = self._signer.key_id
|
|
230
|
+
sealed = []
|
|
231
|
+
for item in pending:
|
|
232
|
+
sequence += 1
|
|
233
|
+
if sequence > MAX_SEQUENCE:
|
|
234
|
+
raise RuntimeError("The ledger reached the maximum sequence number.")
|
|
235
|
+
draft = LedgerEntry(FORMAT_VERSION, self._options.ledger_id, sequence, item.id, timestamp, item.event_type,
|
|
236
|
+
item.payload_json, item.payload_hash, previous, key_id, "", "")
|
|
237
|
+
digest = compute_entry_hash(draft)
|
|
238
|
+
entry = draft.with_changes(hash=digest, signature=self._signer.sign(bytes.fromhex(digest)).hex())
|
|
239
|
+
sealed.append(entry)
|
|
240
|
+
previous = digest
|
|
241
|
+
return sealed
|
|
242
|
+
|
|
243
|
+
def _backoff(self, failures: int) -> float:
|
|
244
|
+
base = self._options.retry_base_delay.total_seconds() * 2 ** (failures - 1)
|
|
245
|
+
return min(base + base * 0.2 * random.random(), 2.0)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _stopped() -> LedgerUnavailableError:
|
|
249
|
+
return LedgerUnavailableError("PROVANCE ledger is closed; the entry was not queued.")
|
provance/checkpoints.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Checkpoint sinks and automatic publication.
|
|
2
|
+
|
|
3
|
+
A checkpoint is only as trustworthy as the place it is stored: use storage the application cannot overwrite
|
|
4
|
+
(object storage with a retention lock, WORM storage, a timestamping service, a system owned by another team).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Callable, Protocol, Sequence
|
|
14
|
+
|
|
15
|
+
from .models import LedgerCheckpoint
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .ledger import ProvanceLedger
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("provance.checkpoints")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LedgerCheckpointSink(Protocol):
|
|
24
|
+
"""Destination of automatic checkpoints. Must not overwrite an existing checkpoint."""
|
|
25
|
+
|
|
26
|
+
def publish(self, checkpoint: LedgerCheckpoint) -> None: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FileCheckpointSink:
|
|
30
|
+
"""Writes each checkpoint to ``{directory}/{ledger_id}/{sequence:016}.json`` and never overwrites a file.
|
|
31
|
+
|
|
32
|
+
Same layout as the .NET and Java file sinks. Useful when the directory is mounted from immutable storage.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, directory: str | Path):
|
|
36
|
+
self.directory = Path(directory)
|
|
37
|
+
|
|
38
|
+
def publish(self, checkpoint: LedgerCheckpoint) -> None:
|
|
39
|
+
folder = _folder(self.directory, checkpoint.ledger_id)
|
|
40
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path = folder / f"{checkpoint.sequence:016d}.json"
|
|
42
|
+
try:
|
|
43
|
+
with open(path, "x", encoding="utf-8", newline="\n") as f: # "x": fails if the file exists
|
|
44
|
+
f.write(checkpoint.to_json())
|
|
45
|
+
except FileExistsError:
|
|
46
|
+
pass # same head already published, possibly by another instance
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_latest_checkpoint(directory: str | Path, ledger_id: str) -> LedgerCheckpoint | None:
|
|
50
|
+
"""Reads the checkpoint with the highest sequence written by a file sink, or ``None``."""
|
|
51
|
+
folder = _folder(Path(directory), ledger_id)
|
|
52
|
+
files = [f for f in folder.glob("*.json") if f.stem.isdigit()] if folder.is_dir() else []
|
|
53
|
+
if not files:
|
|
54
|
+
return None
|
|
55
|
+
return LedgerCheckpoint.from_json(max(files, key=lambda f: int(f.stem)).read_text(encoding="utf-8"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _folder(directory: Path, ledger_id: str) -> Path:
|
|
59
|
+
if not ledger_id or ledger_id in (".", "..") or "/" in ledger_id or "\\" in ledger_id:
|
|
60
|
+
raise ValueError(f"Ledger id '{ledger_id}' cannot be used as a folder name.")
|
|
61
|
+
return directory / ledger_id
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CheckpointPublisher:
|
|
65
|
+
"""Publishes a checkpoint of the head to every sink at the interval, when the head has moved."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, create: Callable[[], LedgerCheckpoint | None], sinks: Sequence[LedgerCheckpointSink],
|
|
68
|
+
interval: timedelta, name: str):
|
|
69
|
+
self._create = create
|
|
70
|
+
self._sinks = list(sinks)
|
|
71
|
+
self._interval = interval.total_seconds()
|
|
72
|
+
self._stop = threading.Event()
|
|
73
|
+
self._last_sequence = 0
|
|
74
|
+
self._lock = threading.Lock()
|
|
75
|
+
self._thread = threading.Thread(target=self._run, name=f"provance-checkpoints-{name}", daemon=True)
|
|
76
|
+
|
|
77
|
+
def start(self) -> None:
|
|
78
|
+
if self._sinks:
|
|
79
|
+
self._thread.start()
|
|
80
|
+
|
|
81
|
+
def stop(self) -> None:
|
|
82
|
+
self._stop.set()
|
|
83
|
+
if self._thread.is_alive():
|
|
84
|
+
self._thread.join(timeout=5)
|
|
85
|
+
|
|
86
|
+
def publish_once(self) -> None:
|
|
87
|
+
with self._lock:
|
|
88
|
+
try:
|
|
89
|
+
checkpoint = self._create()
|
|
90
|
+
if checkpoint is None or checkpoint.sequence == self._last_sequence:
|
|
91
|
+
return
|
|
92
|
+
for sink in self._sinks:
|
|
93
|
+
sink.publish(checkpoint)
|
|
94
|
+
self._last_sequence = checkpoint.sequence
|
|
95
|
+
log.info("PROVANCE checkpoint published at sequence %d.", checkpoint.sequence)
|
|
96
|
+
except Exception:
|
|
97
|
+
log.exception("PROVANCE checkpoint publication failed; it will be retried at the next interval.")
|
|
98
|
+
|
|
99
|
+
def _run(self) -> None:
|
|
100
|
+
while not self._stop.wait(self._interval):
|
|
101
|
+
self.publish_once()
|
provance/cli.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Command line: ``provance verify`` and ``provance export``.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 the ledger is valid, 1 verification failed, 2 usage or connection error.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .models import LedgerCheckpoint
|
|
17
|
+
from .signing import HmacVerifier
|
|
18
|
+
from .stores import DEFAULT_TABLE, JsonLinesReader, SqlLedgerReader, connect, write_json_lines
|
|
19
|
+
from .verifier import LedgerVerifier
|
|
20
|
+
|
|
21
|
+
DEFAULT_KEY_ENV = "PROVANCE_SECRET_KEY"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UsageError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
29
|
+
parser = _parser()
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
if not getattr(args, "command", None):
|
|
32
|
+
parser.print_help()
|
|
33
|
+
return 2
|
|
34
|
+
try:
|
|
35
|
+
return args.handler(args)
|
|
36
|
+
except (UsageError, ValueError, FileNotFoundError, RuntimeError) as e:
|
|
37
|
+
print(f"provance: {e}", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
except Exception as e: # driver errors (connection refused, unknown table, ...)
|
|
40
|
+
print(f"provance: {type(e).__name__}: {e}", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parser() -> argparse.ArgumentParser:
|
|
45
|
+
p = argparse.ArgumentParser(prog="provance", description="Verify and export PROVANCE tamper-evident ledgers.")
|
|
46
|
+
p.add_argument("--version", action="version", version=f"provance {__version__}")
|
|
47
|
+
sub = p.add_subparsers(dest="command")
|
|
48
|
+
|
|
49
|
+
v = sub.add_parser("verify", help="verify a ledger from a database or a JSON Lines export",
|
|
50
|
+
description="Verify the hash chain, signatures and, with --checkpoint, truncation and rewrites.")
|
|
51
|
+
_add_source(v)
|
|
52
|
+
v.add_argument("--ledger-id", required=True, help="ledger id configured in the application")
|
|
53
|
+
v.add_argument("--genesis-hash", help="only if the application configured an explicit genesis hash")
|
|
54
|
+
v.add_argument("--key-id", default="k1", help="id of the current HMAC key (default: k1)")
|
|
55
|
+
v.add_argument("--key-env", default=DEFAULT_KEY_ENV, metavar="VAR",
|
|
56
|
+
help=f"environment variable holding the HMAC secret (default: {DEFAULT_KEY_ENV})")
|
|
57
|
+
v.add_argument("--key-file", metavar="PATH", help="file holding the HMAC secret, instead of --key-env")
|
|
58
|
+
v.add_argument("--retired-key", action="append", default=[], metavar="ID=VAR",
|
|
59
|
+
help="retired key: key id and the environment variable holding its secret (repeatable)")
|
|
60
|
+
v.add_argument("--checkpoint", metavar="PATH",
|
|
61
|
+
help="trusted checkpoint: a checkpoint JSON file, or a checkpoint directory (latest one is used)")
|
|
62
|
+
v.add_argument("--from-checkpoint", action="store_true", help="verify only from the checkpoint onward")
|
|
63
|
+
v.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
64
|
+
v.set_defaults(handler=_verify)
|
|
65
|
+
|
|
66
|
+
e = sub.add_parser("export", help="export a ledger table to JSON Lines (for an offline audit)")
|
|
67
|
+
_add_db(e, required=True)
|
|
68
|
+
e.add_argument("--from", dest="from_sequence", type=int, default=1, metavar="SEQ", help="first sequence (default: 1)")
|
|
69
|
+
e.add_argument("-o", "--output", metavar="PATH", help="output file (default: standard output)")
|
|
70
|
+
e.set_defaults(handler=_export)
|
|
71
|
+
return p
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _add_db(p: argparse.ArgumentParser, required: bool) -> None:
|
|
75
|
+
p.add_argument("--db", required=required, metavar="URL",
|
|
76
|
+
help="database URL: sqlite:///audit.db, postgresql://user@host/db, mysql://..., mssql://...")
|
|
77
|
+
p.add_argument("--table", default=DEFAULT_TABLE, help=f"ledger table (default: {DEFAULT_TABLE})")
|
|
78
|
+
p.add_argument("--schema", help="schema of the ledger table")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _add_source(p: argparse.ArgumentParser) -> None:
|
|
82
|
+
_add_db(p, required=False)
|
|
83
|
+
p.add_argument("--file", metavar="PATH", help="JSON Lines export produced by 'provance export'")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _open_source(args: argparse.Namespace):
|
|
87
|
+
file = getattr(args, "file", None)
|
|
88
|
+
if bool(args.db) == bool(file):
|
|
89
|
+
raise UsageError("give exactly one of --db or --file")
|
|
90
|
+
if file:
|
|
91
|
+
return JsonLinesReader(file), None
|
|
92
|
+
connection, dialect = connect(args.db)
|
|
93
|
+
return SqlLedgerReader(connection, dialect, table=args.table, schema=args.schema), connection
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _secret_from_env(var: str, what: str) -> str:
|
|
97
|
+
value = os.environ.get(var)
|
|
98
|
+
if not value:
|
|
99
|
+
raise UsageError(f"environment variable {var} is not set ({what})")
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _keys(args: argparse.Namespace) -> dict[str, str]:
|
|
104
|
+
if args.key_file:
|
|
105
|
+
secret = Path(args.key_file).read_text(encoding="utf-8").rstrip("\r\n")
|
|
106
|
+
else:
|
|
107
|
+
secret = _secret_from_env(args.key_env, f"secret of key '{args.key_id}'")
|
|
108
|
+
keys = {args.key_id: secret}
|
|
109
|
+
for spec in args.retired_key:
|
|
110
|
+
key_id, sep, var = spec.partition("=")
|
|
111
|
+
if not sep or not key_id or not var:
|
|
112
|
+
raise UsageError(f"--retired-key expects ID=ENV_VAR, got '{spec}'")
|
|
113
|
+
keys[key_id] = _secret_from_env(var, f"secret of retired key '{key_id}'")
|
|
114
|
+
return keys
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_checkpoint(path_text: str | None, ledger_id: str) -> LedgerCheckpoint | None:
|
|
118
|
+
if not path_text:
|
|
119
|
+
return None
|
|
120
|
+
path = Path(path_text)
|
|
121
|
+
if path.is_dir():
|
|
122
|
+
folder = path / ledger_id if (path / ledger_id).is_dir() else path
|
|
123
|
+
files = [f for f in folder.glob("*.json") if f.stem.isdigit()]
|
|
124
|
+
if not files:
|
|
125
|
+
raise UsageError(f"no checkpoint file (NNNN.json) in {folder}")
|
|
126
|
+
path = max(files, key=lambda f: int(f.stem))
|
|
127
|
+
return LedgerCheckpoint.from_json(path.read_text(encoding="utf-8"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _verify(args: argparse.Namespace) -> int:
|
|
131
|
+
if args.from_checkpoint and not args.checkpoint:
|
|
132
|
+
raise UsageError("--from-checkpoint needs --checkpoint")
|
|
133
|
+
keys = _keys(args)
|
|
134
|
+
checkpoint = _load_checkpoint(args.checkpoint, args.ledger_id)
|
|
135
|
+
source, connection = _open_source(args)
|
|
136
|
+
try:
|
|
137
|
+
verifier = LedgerVerifier(source, HmacVerifier(keys), args.ledger_id, args.genesis_hash)
|
|
138
|
+
result = verifier.verify(checkpoint=checkpoint, start_from_checkpoint=args.from_checkpoint)
|
|
139
|
+
finally:
|
|
140
|
+
if connection is not None:
|
|
141
|
+
connection.close()
|
|
142
|
+
|
|
143
|
+
if args.json:
|
|
144
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
145
|
+
else:
|
|
146
|
+
status = "OK" if result.valid else f"FAILED ({result.failure.value})"
|
|
147
|
+
print(f"{status}: {result.message}")
|
|
148
|
+
if checkpoint is not None and result.valid:
|
|
149
|
+
print(f"Checkpoint at sequence {checkpoint.sequence} matched.")
|
|
150
|
+
if result.redacted_payloads:
|
|
151
|
+
print(f"{result.redacted_payloads} payload(s) redacted.")
|
|
152
|
+
if result.valid and result.last_hash:
|
|
153
|
+
print(f"Head: sequence {result.last_sequence}, hash {result.last_hash}")
|
|
154
|
+
return 0 if result.valid else 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _export(args: argparse.Namespace) -> int:
|
|
158
|
+
source, connection = _open_source(args)
|
|
159
|
+
try:
|
|
160
|
+
if args.output:
|
|
161
|
+
with open(args.output, "w", encoding="utf-8", newline="\n") as out:
|
|
162
|
+
count = write_json_lines(source.read(args.from_sequence), out)
|
|
163
|
+
print(f"{count} entries written to {args.output}", file=sys.stderr)
|
|
164
|
+
else:
|
|
165
|
+
write_json_lines(source.read(args.from_sequence), sys.stdout)
|
|
166
|
+
finally:
|
|
167
|
+
connection.close()
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
sys.exit(main())
|
provance/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Exceptions raised by the ledger, the writer and the stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LedgerError(Exception):
|
|
7
|
+
"""Base class of PROVANCE errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LedgerConcurrencyError(LedgerError):
|
|
11
|
+
"""Raised by a store when an entry with the same sequence or id already exists.
|
|
12
|
+
|
|
13
|
+
The writer handles it by reloading the head; applications rarely see it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LedgerStoreError(LedgerError):
|
|
18
|
+
"""A store operation failed (database unavailable, timeout, ...). The writer retries appends that fail this way."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LedgerUnavailableError(LedgerError):
|
|
22
|
+
"""The entry could not be written: the ledger is closed, or the store kept failing after all retries.
|
|
23
|
+
|
|
24
|
+
When the cause is a store failure, the entry may or may not have been persisted.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LedgerOverloadedError(LedgerUnavailableError):
|
|
29
|
+
"""The queue is full and ``queue_full_behavior`` is ``FAIL_FAST``. Map it to HTTP 503 or 429."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PayloadError(ValueError):
|
|
33
|
+
"""The payload cannot be serialized to canonical JSON."""
|