aevum-publish 0.4.0__tar.gz
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.
- aevum_publish-0.4.0/.gitignore +49 -0
- aevum_publish-0.4.0/PKG-INFO +88 -0
- aevum_publish-0.4.0/README.md +65 -0
- aevum_publish-0.4.0/pyproject.toml +62 -0
- aevum_publish-0.4.0/src/aevum/publish/__init__.py +28 -0
- aevum_publish-0.4.0/src/aevum/publish/complication.py +271 -0
- aevum_publish-0.4.0/src/aevum/publish/py.typed +0 -0
- aevum_publish-0.4.0/tests/test_publish_complication.py +222 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.venv/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
|
|
9
|
+
# Build
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
site/
|
|
13
|
+
|
|
14
|
+
# Tools
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.hypothesis/
|
|
19
|
+
.cache/
|
|
20
|
+
|
|
21
|
+
# IDE
|
|
22
|
+
.vscode/
|
|
23
|
+
.idea/
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Verify scripts (run locally, never commit)
|
|
32
|
+
verify_*.py
|
|
33
|
+
scripts/verify_*.py
|
|
34
|
+
|
|
35
|
+
# Aevum development — never commit (Phase 0+)
|
|
36
|
+
aevum_principles.key
|
|
37
|
+
signed_principles_draft.yaml
|
|
38
|
+
tools/sign_principles.py
|
|
39
|
+
|
|
40
|
+
# Private keys — never commit
|
|
41
|
+
*.key
|
|
42
|
+
*.pem
|
|
43
|
+
|
|
44
|
+
# OpenSSF Scorecard output (Phase 0+)
|
|
45
|
+
results.sarif
|
|
46
|
+
verify_phase3.py
|
|
47
|
+
verify_phase7.py
|
|
48
|
+
verify_phase8.py
|
|
49
|
+
verify_phase*.py
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aevum-publish
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Aevum — Sigstore Rekor v2 transparency log complication.
|
|
5
|
+
Project-URL: Homepage, https://aevum.build
|
|
6
|
+
Project-URL: Repository, https://github.com/aevum-labs/aevum
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: aevum-core
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
20
|
+
Provides-Extra: rekor
|
|
21
|
+
Requires-Dist: httpx>=0.27.0; extra == 'rekor'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# aevum-publish
|
|
25
|
+
|
|
26
|
+
Sigstore Rekor v2 transparency log complication for Aevum.
|
|
27
|
+
|
|
28
|
+
Submits periodic chain checkpoints to an external transparency log,
|
|
29
|
+
enabling adversarial-resistant verification: even if an operator is
|
|
30
|
+
compromised, they cannot silently replace the chain without the external
|
|
31
|
+
witness detecting the discrepancy.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install aevum-publish[rekor]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from aevum.core import Engine
|
|
39
|
+
from aevum.publish import PublishComplication
|
|
40
|
+
|
|
41
|
+
engine = Engine()
|
|
42
|
+
comp = PublishComplication(
|
|
43
|
+
rekor_url="https://rekor.sigstore.dev", # or private Rekor
|
|
44
|
+
every_n_events=100, # checkpoint every 100 events
|
|
45
|
+
every_seconds=300, # or every 5 minutes
|
|
46
|
+
)
|
|
47
|
+
engine.install_complication(comp)
|
|
48
|
+
engine.approve_complication("aevum-publish")
|
|
49
|
+
comp.on_approved(engine) # must be called explicitly
|
|
50
|
+
# Chain now contains signed transparency.checkpoint events with Rekor inclusion proofs
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Checkpoint format
|
|
54
|
+
|
|
55
|
+
Each checkpoint is a SHA-256 digest of:
|
|
56
|
+
```json
|
|
57
|
+
{"prior_hash": "...", "sequence": 42, "signer_key_id": "...", "system_time": ...}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Submitted to Rekor as a `hashedrekord` entry. The Rekor log index and
|
|
61
|
+
inclusion proof are stored in the local sigchain as a `transparency.checkpoint`
|
|
62
|
+
AuditEvent, so the chain self-documents its verification history.
|
|
63
|
+
|
|
64
|
+
## Private Rekor
|
|
65
|
+
|
|
66
|
+
For confidential deployments where checkpoint hashes must not be public:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
comp = PublishComplication(rekor_url="https://your-private-rekor.example.com")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Without Rekor
|
|
73
|
+
|
|
74
|
+
If `httpx` is not installed or the Rekor endpoint is unreachable, the
|
|
75
|
+
complication logs a warning and continues. The Engine write path is never
|
|
76
|
+
blocked.
|
|
77
|
+
|
|
78
|
+
## Environment variables
|
|
79
|
+
|
|
80
|
+
| Variable | Default | Description |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `AEVUM_PUBLISH_EVERY_N_EVENTS` | `100` | Submit checkpoint after N events |
|
|
83
|
+
| `AEVUM_PUBLISH_EVERY_SECONDS` | `300` | Submit checkpoint after N seconds |
|
|
84
|
+
|
|
85
|
+
## See also
|
|
86
|
+
|
|
87
|
+
- [ADR-007: Transparency log](../../docs/adrs/adr-007-transparency-log.md)
|
|
88
|
+
- [Sigstore Rekor v2](https://github.com/sigstore/rekor-tiles)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# aevum-publish
|
|
2
|
+
|
|
3
|
+
Sigstore Rekor v2 transparency log complication for Aevum.
|
|
4
|
+
|
|
5
|
+
Submits periodic chain checkpoints to an external transparency log,
|
|
6
|
+
enabling adversarial-resistant verification: even if an operator is
|
|
7
|
+
compromised, they cannot silently replace the chain without the external
|
|
8
|
+
witness detecting the discrepancy.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install aevum-publish[rekor]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from aevum.core import Engine
|
|
16
|
+
from aevum.publish import PublishComplication
|
|
17
|
+
|
|
18
|
+
engine = Engine()
|
|
19
|
+
comp = PublishComplication(
|
|
20
|
+
rekor_url="https://rekor.sigstore.dev", # or private Rekor
|
|
21
|
+
every_n_events=100, # checkpoint every 100 events
|
|
22
|
+
every_seconds=300, # or every 5 minutes
|
|
23
|
+
)
|
|
24
|
+
engine.install_complication(comp)
|
|
25
|
+
engine.approve_complication("aevum-publish")
|
|
26
|
+
comp.on_approved(engine) # must be called explicitly
|
|
27
|
+
# Chain now contains signed transparency.checkpoint events with Rekor inclusion proofs
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Checkpoint format
|
|
31
|
+
|
|
32
|
+
Each checkpoint is a SHA-256 digest of:
|
|
33
|
+
```json
|
|
34
|
+
{"prior_hash": "...", "sequence": 42, "signer_key_id": "...", "system_time": ...}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Submitted to Rekor as a `hashedrekord` entry. The Rekor log index and
|
|
38
|
+
inclusion proof are stored in the local sigchain as a `transparency.checkpoint`
|
|
39
|
+
AuditEvent, so the chain self-documents its verification history.
|
|
40
|
+
|
|
41
|
+
## Private Rekor
|
|
42
|
+
|
|
43
|
+
For confidential deployments where checkpoint hashes must not be public:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
comp = PublishComplication(rekor_url="https://your-private-rekor.example.com")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Without Rekor
|
|
50
|
+
|
|
51
|
+
If `httpx` is not installed or the Rekor endpoint is unreachable, the
|
|
52
|
+
complication logs a warning and continues. The Engine write path is never
|
|
53
|
+
blocked.
|
|
54
|
+
|
|
55
|
+
## Environment variables
|
|
56
|
+
|
|
57
|
+
| Variable | Default | Description |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `AEVUM_PUBLISH_EVERY_N_EVENTS` | `100` | Submit checkpoint after N events |
|
|
60
|
+
| `AEVUM_PUBLISH_EVERY_SECONDS` | `300` | Submit checkpoint after N seconds |
|
|
61
|
+
|
|
62
|
+
## See also
|
|
63
|
+
|
|
64
|
+
- [ADR-007: Transparency log](../../docs/adrs/adr-007-transparency-log.md)
|
|
65
|
+
- [Sigstore Rekor v2](https://github.com/sigstore/rekor-tiles)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aevum-publish"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "Aevum — Sigstore Rekor v2 transparency log complication."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: Apache Software License",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Typing :: Typed",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"aevum-core",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
rekor = ["httpx>=0.27.0"]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
"mypy>=1.10",
|
|
24
|
+
"ruff>=0.9",
|
|
25
|
+
"httpx>=0.27.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.entry-points."aevum.complications"]
|
|
29
|
+
publish = "aevum.publish.complication:PublishComplication"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://aevum.build"
|
|
33
|
+
Repository = "https://github.com/aevum-labs/aevum"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/aevum"]
|
|
41
|
+
|
|
42
|
+
[tool.uv.sources]
|
|
43
|
+
aevum-core = { workspace = true }
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
addopts = "--tb=short"
|
|
48
|
+
pythonpath = ["src", "../../packages/aevum-core/src"]
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
strict = true
|
|
52
|
+
python_version = "3.11"
|
|
53
|
+
mypy_path = "src"
|
|
54
|
+
explicit_package_bases = true
|
|
55
|
+
ignore_missing_imports = true
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
line-length = 100
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "UP", "B", "SIM", "I", "ANN"]
|
|
62
|
+
ignore = ["ANN401"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aevum.publish — Sigstore Rekor v2 transparency log complication.
|
|
3
|
+
|
|
4
|
+
Submits chain checkpoints to an external transparency log, enabling
|
|
5
|
+
adversarial-resistant chain verification. Without external witnessing,
|
|
6
|
+
a compromised operator could silently replace the entire chain.
|
|
7
|
+
|
|
8
|
+
Requires: pip install aevum-publish[rekor] (installs httpx)
|
|
9
|
+
|
|
10
|
+
Without httpx installed: importing succeeds, but checkpoint submission
|
|
11
|
+
warns and skips gracefully.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from aevum.publish import PublishComplication
|
|
15
|
+
comp = PublishComplication(
|
|
16
|
+
rekor_url="https://rekor.sigstore.dev", # or private Rekor
|
|
17
|
+
every_n_events=100,
|
|
18
|
+
every_seconds=300,
|
|
19
|
+
)
|
|
20
|
+
engine.install_complication(comp)
|
|
21
|
+
engine.approve_complication("aevum-publish")
|
|
22
|
+
comp.on_approved(engine) # must be called explicitly — Engine does not auto-call
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from aevum.publish.complication import PublishComplication
|
|
26
|
+
|
|
27
|
+
__version__ = "0.4.0"
|
|
28
|
+
__all__ = ["PublishComplication"]
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PublishComplication — Rekor v2 transparency log integration.
|
|
3
|
+
|
|
4
|
+
ADR-007 implementation. See docs/adrs/adr-007-transparency-log.md.
|
|
5
|
+
|
|
6
|
+
Lifecycle note: the Engine has no automatic on_approved callback mechanism.
|
|
7
|
+
Callers must invoke comp.on_approved(engine) explicitly after
|
|
8
|
+
engine.approve_complication("aevum-publish") to trigger checkpoint submission.
|
|
9
|
+
This matches the pattern established by aevum-spiffe.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_DEFAULT_REKOR_URL = "https://rekor.sigstore.dev"
|
|
25
|
+
_DEFAULT_EVERY_N = int(os.environ.get("AEVUM_PUBLISH_EVERY_N_EVENTS", "100"))
|
|
26
|
+
_DEFAULT_EVERY_S = int(os.environ.get("AEVUM_PUBLISH_EVERY_SECONDS", "300"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _compute_checkpoint_digest(
|
|
30
|
+
sequence: int,
|
|
31
|
+
prior_hash: str,
|
|
32
|
+
signer_key_id: str,
|
|
33
|
+
system_time: int,
|
|
34
|
+
) -> bytes:
|
|
35
|
+
"""
|
|
36
|
+
SHA-256 digest of the canonical checkpoint record.
|
|
37
|
+
|
|
38
|
+
Using SHA-256 (not SHA3-256) because Rekor's hashedrekord spec requires SHA-256.
|
|
39
|
+
The chain's internal integrity uses SHA3-256; the external witness uses SHA-256.
|
|
40
|
+
These are separate trust layers with different hash requirements.
|
|
41
|
+
"""
|
|
42
|
+
record = json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"sequence": sequence,
|
|
45
|
+
"prior_hash": prior_hash,
|
|
46
|
+
"signer_key_id": signer_key_id,
|
|
47
|
+
"system_time": system_time,
|
|
48
|
+
},
|
|
49
|
+
sort_keys=True,
|
|
50
|
+
separators=(",", ":"),
|
|
51
|
+
).encode("utf-8")
|
|
52
|
+
return hashlib.sha256(record).digest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PublishComplication:
|
|
56
|
+
"""
|
|
57
|
+
Transparency log complication. Submits chain checkpoints to Rekor v2.
|
|
58
|
+
|
|
59
|
+
Threshold triggers (whichever comes first):
|
|
60
|
+
- every_n_events: submit after N events are written to the chain
|
|
61
|
+
- every_seconds: submit after M seconds since last submission
|
|
62
|
+
|
|
63
|
+
Failure modes:
|
|
64
|
+
- httpx not installed: warn, skip
|
|
65
|
+
- Rekor unreachable: warn, buffer for next threshold
|
|
66
|
+
- Submission rejected: warn, log details
|
|
67
|
+
|
|
68
|
+
NEVER blocks the Engine write path. NEVER raises in lifecycle hooks.
|
|
69
|
+
|
|
70
|
+
Engine integration (no automatic lifecycle hook):
|
|
71
|
+
engine.install_complication(comp)
|
|
72
|
+
engine.approve_complication("aevum-publish")
|
|
73
|
+
comp.on_approved(engine) # caller must invoke this explicitly
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
name: str = "aevum-publish"
|
|
77
|
+
version: str = "0.4.0"
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
rekor_url: str | None = None,
|
|
82
|
+
every_n_events: int | None = None,
|
|
83
|
+
every_seconds: int | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self._rekor_url = (rekor_url or _DEFAULT_REKOR_URL).rstrip("/")
|
|
86
|
+
self._every_n = every_n_events or _DEFAULT_EVERY_N
|
|
87
|
+
self._every_s = every_seconds or _DEFAULT_EVERY_S
|
|
88
|
+
self._engine: Any = None
|
|
89
|
+
self._events_since_checkpoint: int = 0
|
|
90
|
+
self._last_checkpoint_time: float = time.monotonic()
|
|
91
|
+
self._lock = threading.Lock()
|
|
92
|
+
self._enabled: bool = False
|
|
93
|
+
|
|
94
|
+
def manifest(self) -> dict[str, Any]:
|
|
95
|
+
return {
|
|
96
|
+
"name": self.name,
|
|
97
|
+
"version": self.version,
|
|
98
|
+
"description": "Sigstore Rekor v2 transparency log checkpoints for chain verification",
|
|
99
|
+
"capabilities": ["transparency-log"],
|
|
100
|
+
"classification_max": 0,
|
|
101
|
+
"functions": ["commit"],
|
|
102
|
+
"auth": {"scopes_required": [], "public_key": None},
|
|
103
|
+
"schema_version": "1.0",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# ── Lifecycle hook ────────────────────────────────────────────────────────
|
|
107
|
+
# Mirrors aevum-spiffe pattern: Engine does not call this automatically.
|
|
108
|
+
# Callers must invoke comp.on_approved(engine) explicitly after
|
|
109
|
+
# engine.approve_complication("aevum-publish").
|
|
110
|
+
|
|
111
|
+
def on_approved(self, engine: Any) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Call after engine.approve_complication("aevum-publish") to trigger
|
|
114
|
+
initial checkpoint submission and enable event counting.
|
|
115
|
+
|
|
116
|
+
The Engine does not call this automatically; callers must invoke it.
|
|
117
|
+
"""
|
|
118
|
+
self._engine = engine
|
|
119
|
+
self._enabled = True
|
|
120
|
+
self._try_submit_checkpoint(reason="complication.approved")
|
|
121
|
+
|
|
122
|
+
# ── Event counting hook ───────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def on_event_written(self) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Call after each successful event write to the chain. Checks both
|
|
127
|
+
thresholds and submits a checkpoint if either is met.
|
|
128
|
+
"""
|
|
129
|
+
if not self._enabled:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
with self._lock:
|
|
133
|
+
self._events_since_checkpoint += 1
|
|
134
|
+
n_trigger = self._events_since_checkpoint >= self._every_n
|
|
135
|
+
t_trigger = (time.monotonic() - self._last_checkpoint_time) >= self._every_s
|
|
136
|
+
|
|
137
|
+
if n_trigger or t_trigger:
|
|
138
|
+
reason = "n_events" if n_trigger else "interval"
|
|
139
|
+
self._try_submit_checkpoint(reason=reason)
|
|
140
|
+
|
|
141
|
+
# ── Checkpoint submission ─────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def _try_submit_checkpoint(self, reason: str = "threshold") -> None:
|
|
144
|
+
"""
|
|
145
|
+
Attempt to submit a checkpoint to Rekor. Silently degrades on failure.
|
|
146
|
+
Resets event counter and timer on success.
|
|
147
|
+
"""
|
|
148
|
+
if self._engine is None:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
entries = self._engine.get_ledger_entries()
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
log.warning("aevum-publish: could not read ledger: %s", exc)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if not entries:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
last = entries[-1]
|
|
161
|
+
sequence = last.get("sequence", 0)
|
|
162
|
+
prior_hash = last.get("prior_hash", "")
|
|
163
|
+
signer_key_id = last.get("signer_key_id", "")
|
|
164
|
+
system_time = last.get("system_time", 0)
|
|
165
|
+
|
|
166
|
+
digest = _compute_checkpoint_digest(
|
|
167
|
+
sequence, prior_hash, signer_key_id, system_time
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
log_index, entry_hash = self._submit_to_rekor(digest)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
log.warning(
|
|
174
|
+
"aevum-publish: checkpoint submission failed (%s). "
|
|
175
|
+
"Will retry at next threshold.",
|
|
176
|
+
exc,
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
self._write_checkpoint_event(
|
|
181
|
+
log_index=log_index,
|
|
182
|
+
entry_hash=entry_hash,
|
|
183
|
+
chain_sequence=sequence,
|
|
184
|
+
chain_prior_hash=prior_hash,
|
|
185
|
+
reason=reason,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
with self._lock:
|
|
189
|
+
self._events_since_checkpoint = 0
|
|
190
|
+
self._last_checkpoint_time = time.monotonic()
|
|
191
|
+
|
|
192
|
+
log.info(
|
|
193
|
+
"aevum-publish: checkpoint submitted — sequence=%d rekor_index=%d",
|
|
194
|
+
sequence,
|
|
195
|
+
log_index,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _submit_to_rekor(self, digest: bytes) -> tuple[int, str]:
|
|
199
|
+
"""
|
|
200
|
+
Submit a SHA-256 digest to Rekor v2 as a hashedrekord entry.
|
|
201
|
+
Returns (log_index, entry_hash).
|
|
202
|
+
Raises ImportError if httpx not installed.
|
|
203
|
+
Raises httpx.HTTPError on submission failure.
|
|
204
|
+
|
|
205
|
+
NOTE: The format below matches the Rekor v1 hashedrekord spec. Rekor v2
|
|
206
|
+
(rekor-tiles) uses a different tile-based API; verify against
|
|
207
|
+
https://github.com/sigstore/rekor-tiles/blob/main/CLIENTS.md before
|
|
208
|
+
production use.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
import httpx # noqa: PLC0415
|
|
212
|
+
except ImportError as exc:
|
|
213
|
+
raise ImportError(
|
|
214
|
+
"aevum-publish requires httpx. "
|
|
215
|
+
"Install with: pip install aevum-publish[rekor]"
|
|
216
|
+
) from exc
|
|
217
|
+
|
|
218
|
+
digest_hex = digest.hex()
|
|
219
|
+
|
|
220
|
+
body = {
|
|
221
|
+
"apiVersion": "0.0.1",
|
|
222
|
+
"kind": "hashedrekord",
|
|
223
|
+
"spec": {
|
|
224
|
+
"data": {
|
|
225
|
+
"hash": {
|
|
226
|
+
"algorithm": "sha256",
|
|
227
|
+
"value": digest_hex,
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
resp = httpx.post(
|
|
234
|
+
f"{self._rekor_url}/api/v1/log/entries",
|
|
235
|
+
json=body,
|
|
236
|
+
timeout=30.0,
|
|
237
|
+
)
|
|
238
|
+
resp.raise_for_status()
|
|
239
|
+
|
|
240
|
+
data = resp.json()
|
|
241
|
+
uuid_key = next(iter(data))
|
|
242
|
+
entry = data[uuid_key]
|
|
243
|
+
log_index = int(entry.get("logIndex", entry.get("log_index", -1)))
|
|
244
|
+
return log_index, uuid_key
|
|
245
|
+
|
|
246
|
+
def _write_checkpoint_event(
|
|
247
|
+
self,
|
|
248
|
+
log_index: int,
|
|
249
|
+
entry_hash: str,
|
|
250
|
+
chain_sequence: int,
|
|
251
|
+
chain_prior_hash: str,
|
|
252
|
+
reason: str,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Write transparency.checkpoint AuditEvent to the local sigchain."""
|
|
255
|
+
try:
|
|
256
|
+
self._engine._ledger.append(
|
|
257
|
+
event_type="transparency.checkpoint",
|
|
258
|
+
payload={
|
|
259
|
+
"rekor_log_index": log_index,
|
|
260
|
+
"rekor_entry_hash": entry_hash,
|
|
261
|
+
"rekor_server": self._rekor_url,
|
|
262
|
+
"chain_sequence": chain_sequence,
|
|
263
|
+
"chain_prior_hash": chain_prior_hash,
|
|
264
|
+
"checkpoint_reason": reason,
|
|
265
|
+
},
|
|
266
|
+
actor="aevum-publish",
|
|
267
|
+
)
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
log.error(
|
|
270
|
+
"aevum-publish: failed to write transparency.checkpoint: %s", exc
|
|
271
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for PublishComplication.
|
|
3
|
+
Uses mocked httpx and Engine — no real Rekor instance required.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import unittest.mock
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_mock_engine(entries: list[dict] | None = None) -> unittest.mock.MagicMock:
|
|
15
|
+
"""Minimal engine mock with configurable ledger entries."""
|
|
16
|
+
engine = unittest.mock.MagicMock()
|
|
17
|
+
engine.get_ledger_entries.return_value = entries or [
|
|
18
|
+
{
|
|
19
|
+
"sequence": 3,
|
|
20
|
+
"prior_hash": "a" * 64,
|
|
21
|
+
"signer_key_id": "test-key-id",
|
|
22
|
+
"system_time": 1746000000000000000,
|
|
23
|
+
"event_type": "session.start",
|
|
24
|
+
"actor": "aevum-core",
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
engine._ledger.append = unittest.mock.MagicMock(return_value=None)
|
|
28
|
+
return engine
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_mock_rekor_response(
|
|
32
|
+
log_index: int = 42, uuid: str = "abc123def456"
|
|
33
|
+
) -> unittest.mock.MagicMock:
|
|
34
|
+
"""Mock a successful Rekor submission response."""
|
|
35
|
+
mock_resp = unittest.mock.MagicMock()
|
|
36
|
+
mock_resp.status_code = 201
|
|
37
|
+
mock_resp.raise_for_status = unittest.mock.MagicMock(return_value=None)
|
|
38
|
+
mock_resp.json.return_value = {uuid: {"logIndex": log_index, "body": "..."}}
|
|
39
|
+
return mock_resp
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestPublishComplicationCheckpoint:
|
|
43
|
+
|
|
44
|
+
def test_on_approved_submits_initial_checkpoint(self) -> None:
|
|
45
|
+
"""Approval must trigger an immediate initial checkpoint."""
|
|
46
|
+
from aevum.publish import PublishComplication
|
|
47
|
+
|
|
48
|
+
mock_engine = _make_mock_engine()
|
|
49
|
+
comp = PublishComplication(rekor_url="https://mock.rekor.test")
|
|
50
|
+
mock_resp = _make_mock_rekor_response(log_index=1, uuid="uuid-0001")
|
|
51
|
+
|
|
52
|
+
with unittest.mock.patch("httpx.post", return_value=mock_resp) as mock_post:
|
|
53
|
+
comp.on_approved(mock_engine)
|
|
54
|
+
|
|
55
|
+
mock_post.assert_called_once()
|
|
56
|
+
mock_engine._ledger.append.assert_called_once()
|
|
57
|
+
call_kwargs = mock_engine._ledger.append.call_args.kwargs
|
|
58
|
+
assert call_kwargs["event_type"] == "transparency.checkpoint"
|
|
59
|
+
assert call_kwargs["actor"] == "aevum-publish"
|
|
60
|
+
payload = call_kwargs["payload"]
|
|
61
|
+
assert payload["rekor_log_index"] == 1
|
|
62
|
+
assert payload["rekor_entry_hash"] == "uuid-0001"
|
|
63
|
+
assert payload["rekor_server"] == "https://mock.rekor.test"
|
|
64
|
+
|
|
65
|
+
def test_n_events_threshold_triggers_checkpoint(self) -> None:
|
|
66
|
+
"""After N events, on_event_written must submit a checkpoint."""
|
|
67
|
+
from aevum.publish import PublishComplication
|
|
68
|
+
|
|
69
|
+
mock_engine = _make_mock_engine()
|
|
70
|
+
comp = PublishComplication(
|
|
71
|
+
rekor_url="https://mock.rekor.test",
|
|
72
|
+
every_n_events=3,
|
|
73
|
+
every_seconds=9999,
|
|
74
|
+
)
|
|
75
|
+
mock_resp = _make_mock_rekor_response()
|
|
76
|
+
|
|
77
|
+
with unittest.mock.patch("httpx.post", return_value=mock_resp) as mock_post:
|
|
78
|
+
comp.on_approved(mock_engine) # initial checkpoint (call 1)
|
|
79
|
+
mock_post.reset_mock()
|
|
80
|
+
mock_engine._ledger.append.reset_mock()
|
|
81
|
+
|
|
82
|
+
comp.on_event_written() # 1 of 3
|
|
83
|
+
comp.on_event_written() # 2 of 3
|
|
84
|
+
assert not mock_post.called # not yet
|
|
85
|
+
|
|
86
|
+
comp.on_event_written() # 3 of 3 — threshold reached
|
|
87
|
+
|
|
88
|
+
mock_post.assert_called_once()
|
|
89
|
+
mock_engine._ledger.append.assert_called_once()
|
|
90
|
+
payload = mock_engine._ledger.append.call_args.kwargs["payload"]
|
|
91
|
+
assert payload["checkpoint_reason"] == "n_events"
|
|
92
|
+
|
|
93
|
+
def test_time_threshold_triggers_checkpoint(self) -> None:
|
|
94
|
+
"""After every_seconds, on_event_written must submit a checkpoint."""
|
|
95
|
+
from aevum.publish import PublishComplication
|
|
96
|
+
|
|
97
|
+
mock_engine = _make_mock_engine()
|
|
98
|
+
comp = PublishComplication(
|
|
99
|
+
rekor_url="https://mock.rekor.test",
|
|
100
|
+
every_n_events=9999,
|
|
101
|
+
every_seconds=1,
|
|
102
|
+
)
|
|
103
|
+
mock_resp = _make_mock_rekor_response()
|
|
104
|
+
|
|
105
|
+
with unittest.mock.patch("httpx.post", return_value=mock_resp) as mock_post:
|
|
106
|
+
comp.on_approved(mock_engine)
|
|
107
|
+
mock_post.reset_mock()
|
|
108
|
+
mock_engine._ledger.append.reset_mock()
|
|
109
|
+
|
|
110
|
+
comp._last_checkpoint_time = time.monotonic() - 2.0
|
|
111
|
+
comp.on_event_written()
|
|
112
|
+
|
|
113
|
+
mock_post.assert_called_once()
|
|
114
|
+
payload = mock_engine._ledger.append.call_args.kwargs["payload"]
|
|
115
|
+
assert payload["checkpoint_reason"] == "interval"
|
|
116
|
+
|
|
117
|
+
def test_rekor_failure_does_not_raise(self) -> None:
|
|
118
|
+
"""Rekor submission failure must warn and not raise."""
|
|
119
|
+
from aevum.publish import PublishComplication
|
|
120
|
+
|
|
121
|
+
mock_engine = _make_mock_engine()
|
|
122
|
+
comp = PublishComplication(
|
|
123
|
+
rekor_url="https://mock.rekor.test",
|
|
124
|
+
every_n_events=1,
|
|
125
|
+
every_seconds=9999,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
import httpx
|
|
129
|
+
|
|
130
|
+
with unittest.mock.patch(
|
|
131
|
+
"httpx.post", side_effect=httpx.ConnectError("Connection refused")
|
|
132
|
+
):
|
|
133
|
+
comp.on_approved(mock_engine)
|
|
134
|
+
comp.on_event_written()
|
|
135
|
+
|
|
136
|
+
assert mock_engine._ledger.append.call_count == 0
|
|
137
|
+
|
|
138
|
+
def test_httpx_missing_degrades_gracefully(self) -> None:
|
|
139
|
+
"""Missing httpx must warn and not raise."""
|
|
140
|
+
from aevum.publish import PublishComplication
|
|
141
|
+
|
|
142
|
+
mock_engine = _make_mock_engine()
|
|
143
|
+
comp = PublishComplication()
|
|
144
|
+
|
|
145
|
+
with unittest.mock.patch.dict(sys.modules, {"httpx": None}):
|
|
146
|
+
comp.on_approved(mock_engine)
|
|
147
|
+
|
|
148
|
+
mock_engine._ledger.append.assert_not_called()
|
|
149
|
+
|
|
150
|
+
def test_checkpoint_digest_is_deterministic(self) -> None:
|
|
151
|
+
"""Same inputs must always produce same digest."""
|
|
152
|
+
from aevum.publish.complication import _compute_checkpoint_digest
|
|
153
|
+
|
|
154
|
+
d1 = _compute_checkpoint_digest(42, "a" * 64, "key-id", 1746000000)
|
|
155
|
+
d2 = _compute_checkpoint_digest(42, "a" * 64, "key-id", 1746000000)
|
|
156
|
+
assert d1 == d2
|
|
157
|
+
assert len(d1) == 32 # SHA-256 = 32 bytes
|
|
158
|
+
|
|
159
|
+
def test_checkpoint_digest_uses_sha256_not_sha3(self) -> None:
|
|
160
|
+
"""Rekor expects SHA-256; chain internal integrity uses SHA3-256."""
|
|
161
|
+
from aevum.publish.complication import _compute_checkpoint_digest
|
|
162
|
+
|
|
163
|
+
digest = _compute_checkpoint_digest(1, "0" * 64, "k", 0)
|
|
164
|
+
record = json.dumps(
|
|
165
|
+
{
|
|
166
|
+
"sequence": 1,
|
|
167
|
+
"prior_hash": "0" * 64,
|
|
168
|
+
"signer_key_id": "k",
|
|
169
|
+
"system_time": 0,
|
|
170
|
+
},
|
|
171
|
+
sort_keys=True,
|
|
172
|
+
separators=(",", ":"),
|
|
173
|
+
).encode()
|
|
174
|
+
expected = hashlib.sha256(record).digest()
|
|
175
|
+
assert digest == expected
|
|
176
|
+
|
|
177
|
+
def test_rekor_url_trailing_slash_stripped(self) -> None:
|
|
178
|
+
"""Trailing slash in rekor_url must not double-slash the endpoint."""
|
|
179
|
+
from aevum.publish import PublishComplication
|
|
180
|
+
|
|
181
|
+
comp = PublishComplication(rekor_url="https://rekor.example.com/")
|
|
182
|
+
assert not comp._rekor_url.endswith("/")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestPublishComplicationIntegration:
|
|
186
|
+
|
|
187
|
+
def test_transparency_checkpoint_in_sigchain(self) -> None:
|
|
188
|
+
"""transparency.checkpoint must be a verifiable, signed chain entry."""
|
|
189
|
+
# Phase 0 finding: Engine does not call on_approved automatically.
|
|
190
|
+
# Callers must invoke comp.on_approved(engine) explicitly after
|
|
191
|
+
# engine.approve_complication("aevum-publish"). Same pattern as aevum-spiffe.
|
|
192
|
+
sys.path.insert(0, "packages/aevum-core/src")
|
|
193
|
+
from aevum.core import Engine # noqa: I001
|
|
194
|
+
from aevum.publish import PublishComplication
|
|
195
|
+
|
|
196
|
+
mock_resp = _make_mock_rekor_response(log_index=99, uuid="test-uuid-publish")
|
|
197
|
+
|
|
198
|
+
comp = PublishComplication(
|
|
199
|
+
rekor_url="https://mock.rekor.test",
|
|
200
|
+
every_n_events=9999,
|
|
201
|
+
every_seconds=9999,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
engine = Engine()
|
|
205
|
+
|
|
206
|
+
with unittest.mock.patch("httpx.post", return_value=mock_resp):
|
|
207
|
+
engine.install_complication(comp)
|
|
208
|
+
engine.approve_complication("aevum-publish")
|
|
209
|
+
comp.on_approved(engine) # must be called explicitly per aevum-spiffe pattern
|
|
210
|
+
|
|
211
|
+
entries = engine.get_ledger_entries()
|
|
212
|
+
event_types = [e["event_type"] for e in entries]
|
|
213
|
+
|
|
214
|
+
assert "transparency.checkpoint" in event_types, (
|
|
215
|
+
f"transparency.checkpoint not in chain. Events: {event_types}"
|
|
216
|
+
)
|
|
217
|
+
assert engine.verify_sigchain() is True
|
|
218
|
+
|
|
219
|
+
cp = next(e for e in entries if e["event_type"] == "transparency.checkpoint")
|
|
220
|
+
assert cp["payload"]["rekor_log_index"] == 99
|
|
221
|
+
assert cp["payload"]["rekor_server"] == "https://mock.rekor.test"
|
|
222
|
+
assert cp["actor"] == "aevum-publish"
|