Skip to content

v0.48.0: external time anchor#180

Merged
vaaraio merged 2 commits into
mainfrom
release/v0.48.0-time-anchor
May 31, 2026
Merged

v0.48.0: external time anchor#180
vaaraio merged 2 commits into
mainfrom
release/v0.48.0-time-anchor

Conversation

@vaaraio
Copy link
Copy Markdown
Owner

@vaaraio vaaraio commented May 31, 2026

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 verifiers verify_anchor / verify_anchor_over_records.
  • The verifier checks the token is CMS SignedData over a TSTInfo, the message imprint equals the expected digest, the signed message-digest matches the eContent, and the TSA signature verifies under the embedded certificate. Negative tests cover a wrong-digest token, a tampered token, and a refused request.
  • verify_anchor_over_records binds an anchor to a specific record in a trail, so a rewritten chain or a token over a different value is rejected.
  • HTTP uses the standard library. Only the ASN.1 and signature checks need the new optional timeanchor extra (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 with statement" 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.py stands 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

    • External time anchoring via RFC 3161 timestamps for audit trails with offline verification capability
    • Optional installation extra for time anchoring dependencies
    • Server execution record specifications with cryptographic signatures for MCP tool calls
  • Documentation

    • Updated project positioning emphasizing EU AI Act runtime evidence layer
    • Time anchoring documentation with usage examples
  • Chores

    • Version bumped to 0.48.0

vaaraio added 2 commits May 31, 2026 11:07
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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This 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 AuditTrail, and includes extensive test coverage with an in-process TSA fixture.

Changes

v0.48.0 Release with External Time Anchoring

Layer / File(s) Summary
Version and Project Messaging Updates
pyproject.toml, src/vaara/__init__.py, clients/ts/package.json, server.json, server-vaara-server.json, CHANGELOG.md
Version bumped to 0.48.0 across all package metadata. Descriptions updated to emphasize EU AI Act runtime evidence and external chain-head anchoring via RFC 3161 / eIDAS.
User-Facing Documentation
README.md
Intro copy and bullet points reframed around EU AI Act context. New "External time anchor" section explains RFC 3161 anchoring workflow, vaara[timeanchor] installation, and RFC3161TimeAnchorClient usage example.
SEP Specification: Signed Execution Records
docs/sep/sep-server-execution-record.md
New SEP-XXXX document defining cryptographically signed pre-execution decision records and post-execution outcome records for MCP tool calls. Includes trust boundary, JCS canonicalization, detached signatures, schema definitions, verification algorithm (signature/digest/pairing validation), security implications with external time anchoring defense, prior art reconciliation, reference implementation mapping, and test vector requirements.
TimeAnchor Module: RFC 3161 Integration
src/vaara/audit/timeanchor.py
New module implementing RFC 3161 Time-Stamp Authority anchoring: builds RFC 3161 TimeStampReq requests, parses/validates TimeStampResp tokens, performs offline CMS signature verification over signed attributes, validates message-imprint digest/algorithm matching, exposes RFC3161TimeAnchorClient with injectable transport (HTTP POST default via urllib), and provides verify_anchor and verify_anchor_over_records for token re-verification and record-position binding.
AuditTrail Anchoring Support
src/vaara/audit/trail.py
AuditTrail gains _anchors collection, anchors property (returns copy), and anchor_head(client) method that snapshots the latest record under lock, calls the external time-anchor client, appends the anchor under lock, and raises ValueError if the trail is empty.
TimeAnchor Test Suite
tests/test_timeanchor.py
New comprehensive test module with in-process RFC 3161 TSA fixture (EC key + self-signed cert). Tests cover: timestamp token verification roundtrip, digest mismatch/tampering rejection, RFC3161TimeAnchorClient end-to-end anchoring with injected local transport, client refusal behavior, trail integration (anchor_head on live chain), empty trail rejection, verify_anchor_over_records positive case (hash match at position), and negative case (chain rewrite detection).
Test Lifecycle Cleanup
tests/test_sqlite_backend.py, tests/test_v040_tenant.py
Refactored SQLite backend lifecycle from manual try/finally close calls to with SQLiteAuditBackend(...) as backend: context managers in two existing test methods.

🐰 A timestamp from afar now seals our chain so clear,
RFC 3161 makes the "when" appear,
EU AI Act compliance takes its bow,
With signed records and anchors we show how,
Evidence tamper-proof, verified with care,
The audit trail truths we're proud to share! 🔐

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the two main changes: external time anchor implementation (RFC 3161) and the EU AI Act reframing mentioned throughout modified documentation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release/v0.48.0-time-anchor

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 9eb7d1b and c8c426e.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • README.md
  • clients/ts/package.json
  • docs/sep/sep-server-execution-record.md
  • pyproject.toml
  • server-vaara-server.json
  • server.json
  • src/vaara/__init__.py
  • src/vaara/audit/timeanchor.py
  • src/vaara/audit/trail.py
  • tests/test_sqlite_backend.py
  • tests/test_timeanchor.py
  • tests/test_v040_tenant.py

Comment on lines +372 to +374
2. Recompute `attestationDigest` from the SEP-2787 attestation wire bytes and
confirm both records' `backLink.attestationDigest` and
`backLink.attestationNonce` match it. Reject on mismatch.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +319 to +333
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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.

Comment on lines +366 to +377
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Comment thread src/vaara/audit/trail.py
Comment on lines +568 to +571
@property
def anchors(self) -> list:
"""External time anchors recorded over this trail's chain heads."""
return list(self._anchors)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Comment thread src/vaara/audit/trail.py
Comment on lines +1150 to +1173
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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.

@vaaraio vaaraio merged commit 314ef77 into main May 31, 2026
13 checks passed
@vaaraio vaaraio deleted the release/v0.48.0-time-anchor branch May 31, 2026 08:37
@vaaraio vaaraio changed the title v0.48.0: external time anchor + EU-wedge reframe v0.48.0: external time anchor May 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant