v0.48.0: external time anchor#180
Conversation
Adds vaara.audit.timeanchor: AuditTrail.anchor_head() obtains an RFC 3161 trusted timestamp over the current chain head (a SHA-256 digest) from an external Time-Stamp Authority, so the chain's existence is provable against a clock outside Vaara's trust boundary even if the signing key is later compromised. RFC 3161 underpins eIDAS qualified electronic timestamps, which makes this regulator-grade evidence under EU AI Act Article 12. Token verification is offline (verify_anchor, verify_anchor_over_records) and binds the anchor to a specific record, so a rewritten chain or a token over a different digest is rejected. HTTP uses the stdlib; the ASN.1 and signature checks need the new optional 'timeanchor' extra (asn1crypto + cryptography). This is the anti-backdating mechanism cited by the new server-side signed execution-record SEP draft (docs/sep/sep-server-execution-record.md), the follow-up to SEP-2817 that Vaara is positioned to author. Also reframes the public lead back to EU AI Act runtime evidence and data sovereignty (README, package descriptions, MCP manifests, vaara.io) with the tamper-evident receipt as the mechanism, not the headline. 1096 passed / 12 skipped, ruff clean, mypy clean on the new module.
…ests Clears three CodeQL 'should use a with statement' advisories on the v0.47 audit tests (test_sqlite_backend.py, test_v040_tenant.py). The fix was authored during PR #179 but its commit never reached the remote before that PR squash-merged, so the advisories are still live on main; this lands it. No behavior change.
📝 WalkthroughWalkthroughThis release adds RFC 3161 external time anchoring to the audit hash chain. The v0.48.0 version bumps project messaging to emphasize EU AI Act runtime evidence, introduces a comprehensive SEP specification for signed execution records, implements offline timestamp verification, integrates anchoring into Changesv0.48.0 Release with External Time Anchoring
🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/sep/sep-server-execution-record.md`:
- Around line 372-374: Update Step 2 wording to clearly separate which value is
compared to the recomputed SEP-2787 digest and which value is compared to the
nonce: state that recompute the attestationDigest from the SEP-2787 attestation
wire bytes and confirm backLink.attestationDigest equals that recomputed digest,
and separately confirm backLink.attestationNonce equals the attestation nonce
(or, in non-2787 binding mode, the server-chosen nonce). Use the symbols
attestationDigest and backLink.attestationNonce in the sentence to make the two
distinct comparisons explicit.
In `@src/vaara/audit/timeanchor.py`:
- Around line 366-377: _digest_bytes currently indexes
_DIGEST_LEN[hash_algorithm] directly which raises KeyError for unknown stored
algorithms; update _digest_bytes to validate hash_algorithm exists in
_DIGEST_LEN (same guard used by build_timestamp_request) and raise a
TimeAnchorError with a clear message if it's not present, then proceed to check
byte length using the looked-up digest length; reference _digest_bytes,
_DIGEST_LEN, build_timestamp_request, and TimeAnchorError when making the
change.
- Around line 319-333: The constructor currently only stores tsa_url and a
transport but does not pin or validate the TSA identity, so anchor() accepts any
token whose signature verifies under its embedded cert; to fix this add a
configurable TSA identity check in __init__ (e.g., accept a
pinned_cert_fingerprint, pinned_public_key, expected_certificate_subject, or
trusted_ca_bundle parameter) and enforce it when validating tokens in anchor():
fetch/derive the certificate presented by the token and compare it to the
configured identity (or validate it chains to the provided trusted_ca_bundle),
and if it does not match raise TimeAnchorError; use existing symbols tsa_url,
__init__, _transport and anchor to locate where to add the parameter and perform
the check.
In `@src/vaara/audit/trail.py`:
- Around line 568-571: The anchors property reads self._anchors without
acquiring the same self._lock used by anchor_head() (which appends to _anchors);
change anchors to snapshot _anchors under the trail lock like _records does:
acquire self._lock, copy/list(self._anchors) into a local variable, release the
lock and return the copy. Ensure you reference the anchors property, the
_anchors attribute, and self._lock when making the change.
- Around line 1150-1173: anchor_head currently appends a TimeAnchor only to the
in-memory _anchors, so anchors are lost on restart or on export/import; change
anchor_head to persist the new anchor after appending (e.g., call the trail's
persistence hook or save/export routine immediately after
self._anchors.append(anchor)), and update the trail's export_json and
export_jsonl (and corresponding import/loader) to include and restore the
anchors list so exported JSON/JSONL round-trips preserve TimeAnchor entries;
reference anchor_head, _anchors, export_json, and export_jsonl in your changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 47dab28f-5995-468d-8d3b-c91721d11dea
📒 Files selected for processing (13)
CHANGELOG.mdREADME.mdclients/ts/package.jsondocs/sep/sep-server-execution-record.mdpyproject.tomlserver-vaara-server.jsonserver.jsonsrc/vaara/__init__.pysrc/vaara/audit/timeanchor.pysrc/vaara/audit/trail.pytests/test_sqlite_backend.pytests/test_timeanchor.pytests/test_v040_tenant.py
| 2. Recompute `attestationDigest` from the SEP-2787 attestation wire bytes and | ||
| confirm both records' `backLink.attestationDigest` and | ||
| `backLink.attestationNonce` match it. Reject on mismatch. |
There was a problem hiding this comment.
Fix nonce verification wording in Step 2 (digest vs nonce source).
Line 372–374 currently says both backLink.attestationDigest and backLink.attestationNonce should “match it” (the recomputed digest). That is incorrect/ambiguous. attestationDigest should match the recomputed digest, while attestationNonce should match the attestation nonce (or the server-chosen nonce in the non-2787 binding mode).
Suggested wording
-2. Recompute `attestationDigest` from the SEP-2787 attestation wire bytes and
- confirm both records' `backLink.attestationDigest` and
- `backLink.attestationNonce` match it. Reject on mismatch.
+2. Recompute `attestationDigest` from the SEP-2787 attestation wire bytes and
+ confirm both records' `backLink.attestationDigest` match it. Also confirm
+ both records' `backLink.attestationNonce` equals the SEP-2787
+ `issuerAsserted.nonce` (or, when SEP-2787 is absent, equals the
+ deployment-defined per-call nonce from the alternate binding mode). Reject on mismatch.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/sep/sep-server-execution-record.md` around lines 372 - 374, Update Step
2 wording to clearly separate which value is compared to the recomputed SEP-2787
digest and which value is compared to the nonce: state that recompute the
attestationDigest from the SEP-2787 attestation wire bytes and confirm
backLink.attestationDigest equals that recomputed digest, and separately confirm
backLink.attestationNonce equals the attestation nonce (or, in non-2787 binding
mode, the server-chosen nonce). Use the symbols attestationDigest and
backLink.attestationNonce in the sentence to make the two distinct comparisons
explicit.
| def __init__( | ||
| self, | ||
| tsa_url: str, | ||
| *, | ||
| hash_algorithm: str = "sha256", | ||
| timeout: float = 10.0, | ||
| transport: Optional[Transport] = None, | ||
| ) -> None: | ||
| _require_deps() | ||
| if hash_algorithm not in _DIGEST_LEN: | ||
| raise TimeAnchorError(f"unsupported hash algorithm: {hash_algorithm!r}") | ||
| self.tsa_url = tsa_url | ||
| self.hash_algorithm = hash_algorithm | ||
| self.timeout = timeout | ||
| self._transport = transport or _urllib_transport |
There was a problem hiding this comment.
Authenticate the configured TSA, not just the token.
anchor() currently accepts any token whose signature verifies under its embedded certificate, but this client exposes no way to pin or validate the TSA identity. That means a MITM or wrong tsa_url can mint a fresh self-signed token and Vaara will store it as a valid anchor, which breaks the core trust guarantee of the feature.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/vaara/audit/timeanchor.py` around lines 319 - 333, The constructor
currently only stores tsa_url and a transport but does not pin or validate the
TSA identity, so anchor() accepts any token whose signature verifies under its
embedded cert; to fix this add a configurable TSA identity check in __init__
(e.g., accept a pinned_cert_fingerprint, pinned_public_key,
expected_certificate_subject, or trusted_ca_bundle parameter) and enforce it
when validating tokens in anchor(): fetch/derive the certificate presented by
the token and compare it to the configured identity (or validate it chains to
the provided trusted_ca_bundle), and if it does not match raise TimeAnchorError;
use existing symbols tsa_url, __init__, _transport and anchor to locate where to
add the parameter and perform the check.
| def _digest_bytes(chain_head_hash: str, hash_algorithm: str) -> bytes: | ||
| try: | ||
| digest = bytes.fromhex(chain_head_hash) | ||
| except ValueError as exc: | ||
| raise TimeAnchorError( | ||
| f"chain_head_hash is not hex: {chain_head_hash!r}" | ||
| ) from exc | ||
| if len(digest) != _DIGEST_LEN[hash_algorithm]: | ||
| raise TimeAnchorError( | ||
| f"chain_head_hash is {len(digest)} bytes, not a {hash_algorithm} digest" | ||
| ) | ||
| return digest |
There was a problem hiding this comment.
Fail cleanly on unknown stored hash algorithms.
_digest_bytes() indexes _DIGEST_LEN[hash_algorithm] directly, so a bad persisted TimeAnchor.hash_algorithm escapes as KeyError instead of the module’s public TimeAnchorError. Guard this the same way build_timestamp_request() does so corrupted anchors fail predictably.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/vaara/audit/timeanchor.py` around lines 366 - 377, _digest_bytes
currently indexes _DIGEST_LEN[hash_algorithm] directly which raises KeyError for
unknown stored algorithms; update _digest_bytes to validate hash_algorithm
exists in _DIGEST_LEN (same guard used by build_timestamp_request) and raise a
TimeAnchorError with a clear message if it's not present, then proceed to check
byte length using the looked-up digest length; reference _digest_bytes,
_DIGEST_LEN, build_timestamp_request, and TimeAnchorError when making the
change.
| @property | ||
| def anchors(self) -> list: | ||
| """External time anchors recorded over this trail's chain heads.""" | ||
| return list(self._anchors) |
There was a problem hiding this comment.
Snapshot anchors under the trail lock.
anchor_head() appends to _anchors under self._lock, but the property reads it without the same lock. This class already snapshots _records under lock for the same reason, so anchors should follow that pattern too.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/vaara/audit/trail.py` around lines 568 - 571, The anchors property reads
self._anchors without acquiring the same self._lock used by anchor_head() (which
appends to _anchors); change anchors to snapshot _anchors under the trail lock
like _records does: acquire self._lock, copy/list(self._anchors) into a local
variable, release the lock and return the copy. Ensure you reference the anchors
property, the _anchors attribute, and self._lock when making the change.
| def anchor_head(self, client: Any) -> Any: | ||
| """Anchor the current chain head to an external time authority. | ||
|
|
||
| ``client`` is a time-anchor backend such as | ||
| ``vaara.audit.timeanchor.RFC3161TimeAnchorClient``. Reads the latest | ||
| record's hash under the lock, obtains a trusted timestamp over it, and | ||
| appends the resulting ``TimeAnchor`` to this trail's anchor list. The | ||
| anchor proves the chain head existed no later than the attested time, | ||
| attested by a party outside Vaara's trust boundary, which is what | ||
| defeats post-hoc backdating after a signing-key compromise. | ||
|
|
||
| Raises ``ValueError`` if the trail is empty (nothing to anchor) and | ||
| ``TimeAnchorError`` if the authority cannot be reached or its token | ||
| does not verify. The anchor is appended only after the token verifies. | ||
| """ | ||
| with self._lock: | ||
| if not self._records: | ||
| raise ValueError("cannot anchor an empty trail") | ||
| position = len(self._records) - 1 | ||
| head_hash = self._records[-1].record_hash | ||
| anchor = client.anchor(position, head_hash) | ||
| with self._lock: | ||
| self._anchors.append(anchor) | ||
| return anchor |
There was a problem hiding this comment.
Persist anchors instead of keeping them process-local.
anchor_head() only appends the new evidence to _anchors, and none of this class’s existing export/persistence paths include anchors. A restart or export_json/jsonl() round-trip will silently discard the timestamp proof, which makes the new evidence non-durable.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/vaara/audit/trail.py` around lines 1150 - 1173, anchor_head currently
appends a TimeAnchor only to the in-memory _anchors, so anchors are lost on
restart or on export/import; change anchor_head to persist the new anchor after
appending (e.g., call the trail's persistence hook or save/export routine
immediately after self._anchors.append(anchor)), and update the trail's
export_json and export_jsonl (and corresponding import/loader) to include and
restore the anchors list so exported JSON/JSONL round-trips preserve TimeAnchor
entries; reference anchor_head, _anchors, export_json, and export_jsonl in your
changes.
v0.48.0: external time anchor + EU-wedge reframe
What this is
The audit hash chain proves order and integrity, but not when it existed. Every timestamp comes from the runtime's own clock and is signed on export by the runtime's own key, so a later signing-key compromise lets an attacker forge a backdated chain that nothing internal can distinguish from the original.
This adds an external time anchor that closes that gap.
AuditTrail.anchor_head(client)takes the current chain head (already a SHA-256 digest) and obtains an RFC 3161 trusted timestamp over it from an authority the runtime does not control. RFC 3161 is the basis for eIDAS qualified electronic timestamps, so a qualified TSA makes this regulator-grade evidence under EU AI Act Article 12.This is the anti-backdating mechanism the server-side signed execution-record SEP relies on (draft included at
docs/sep/sep-server-execution-record.md).Design
vaara.audit.timeanchor:RFC3161TimeAnchorClient(build request, parse response, verify token),TimeAnchor, and offline verifiersverify_anchor/verify_anchor_over_records.verify_anchor_over_recordsbinds an anchor to a specific record in a trail, so a rewritten chain or a token over a different value is rejected.timeanchorextra (asn1crypto+cryptography); core install stays dependency-free.Public framing
Leads back to EU AI Act runtime evidence and data sovereignty (runs in your own environment, no SaaS, no telemetry) across the README, package descriptions, MCP manifests, and vaara.io. The tamper-evident receipt stays the mechanism, not the headline.
Also
Lands the three CodeQL "use a
withstatement" advisories from the v0.47 audit tests (the fix never reached the remote before #179 squash-merged).Verification
1096 passed / 12 skipped, ruff clean, mypy clean on the new module. New
tests/test_timeanchor.pystands up a self-contained in-process TSA, so the full request / response / verify path runs with no network.Summary by CodeRabbit
Release Notes – v0.48.0
New Features
Documentation
Chores