vaid-pop 0.1.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.
- vaid_pop-0.1.0/.gitignore +24 -0
- vaid_pop-0.1.0/PKG-INFO +67 -0
- vaid_pop-0.1.0/README.md +43 -0
- vaid_pop-0.1.0/pyproject.toml +62 -0
- vaid_pop-0.1.0/tests/test_completion_conformance.py +87 -0
- vaid_pop-0.1.0/tests/test_conformance.py +110 -0
- vaid_pop-0.1.0/vaid_pop/__init__.py +43 -0
- vaid_pop-0.1.0/vaid_pop/completion.py +72 -0
- vaid_pop-0.1.0/vaid_pop/conformance.py +218 -0
- vaid_pop-0.1.0/vaid_pop/signer.py +142 -0
- vaid_pop-0.1.0/vaid_pop/vectors/completion_v1.json +27 -0
- vaid_pop-0.1.0/vaid_pop/vectors/operator_pop_v1.json +20 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
/target
|
|
3
|
+
**/*.rs.bk
|
|
4
|
+
Cargo.lock.orig
|
|
5
|
+
|
|
6
|
+
# Backtraces
|
|
7
|
+
rustc-ice-*.txt
|
|
8
|
+
|
|
9
|
+
# Editor / OS
|
|
10
|
+
.DS_Store
|
|
11
|
+
*.swp
|
|
12
|
+
.idea/
|
|
13
|
+
.vscode/
|
|
14
|
+
|
|
15
|
+
# Python
|
|
16
|
+
__pycache__/
|
|
17
|
+
*.py[cod]
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
dist/
|
|
21
|
+
build/
|
|
22
|
+
.venv/
|
|
23
|
+
.buildvenv/
|
|
24
|
+
.installtest/
|
vaid_pop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vaid-pop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Canonical Python proof-of-possession (PoP) request signer for the VAID standard.
|
|
5
|
+
Project-URL: Homepage, https://github.com/solara-associates/vaid
|
|
6
|
+
Project-URL: Repository, https://github.com/solara-associates/vaid
|
|
7
|
+
Project-URL: Source, https://github.com/solara-associates/vaid/tree/main/python/vaid-pop
|
|
8
|
+
Project-URL: Issues, https://github.com/solara-associates/vaid/issues
|
|
9
|
+
Author-email: "solara.associates" <info@solara.associates>
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
Keywords: agent-identity,cryptography,ed25519,proof-of-possession,signing,vaid
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: cryptography>=42.0
|
|
20
|
+
Requires-Dist: rfc8785>=0.1.2
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# vaid-pop
|
|
26
|
+
|
|
27
|
+
Canonical Python proof-of-possession (PoP) request signer for the
|
|
28
|
+
[VAID](https://github.com/solara-associates/vaid) standard.
|
|
29
|
+
|
|
30
|
+
This is the **single Python definition** of the PoP signing contract. A
|
|
31
|
+
VAID-bound request carries a fresh, replay-protected Ed25519 signature; this
|
|
32
|
+
package builds the four `x-synthera-*` headers that carry it. Import it
|
|
33
|
+
directly — no extra runtime, no framework.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from vaid_pop import RequestSigner
|
|
37
|
+
|
|
38
|
+
signer = RequestSigner(vaid=vaid_doc, private_key=agent_key)
|
|
39
|
+
headers = signer.sign_headers("POST", "/vaid/mint", body_bytes)
|
|
40
|
+
# -> {"x-synthera-vaid": ..., "x-synthera-timestamp": ...,
|
|
41
|
+
# "x-synthera-nonce": ..., "x-synthera-signature": ...}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> The `x-synthera-*` header names are the VAID wire contract — the prefix is the
|
|
45
|
+
> fixed header namespace a conforming verifier reads, not a package dependency.
|
|
46
|
+
|
|
47
|
+
## The firewall
|
|
48
|
+
|
|
49
|
+
Cross-language byte-identity is the whole point. The signer is locked against the
|
|
50
|
+
frozen cross-language vector (vendored here at
|
|
51
|
+
`vaid_pop/vectors/operator_pop_v1.json`), which the Rust client (`vaid-client`)
|
|
52
|
+
and the verifier (`vaid_pop`) assert against too. The `pop-conformance` CI job
|
|
53
|
+
proves **Rust output == Python output == vector** byte-for-byte. A mismatch is a
|
|
54
|
+
hard blocker.
|
|
55
|
+
|
|
56
|
+
Contract: RFC 8785 (JCS) over the camelCase `RequestAuthPayload` → SHA-256 →
|
|
57
|
+
pure Ed25519 over the 32-byte digest → raw 64-byte signature.
|
|
58
|
+
|
|
59
|
+
> Standardized on `rfc8785` for JCS — the byte-for-byte JCS implementation the
|
|
60
|
+
> vector is proven against. Do not substitute another JCS library without
|
|
61
|
+
> re-proving byte-equality.
|
|
62
|
+
|
|
63
|
+
## Relationship to other packages
|
|
64
|
+
|
|
65
|
+
- **`vaid-client`** (Rust) is the cross-language peer, proven against the same vector.
|
|
66
|
+
- Language-specific agent integrations depend on this package and re-export `RequestSigner`.
|
|
67
|
+
- Clients consume this directly to authenticate a VAID-bound request.
|
vaid_pop-0.1.0/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# vaid-pop
|
|
2
|
+
|
|
3
|
+
Canonical Python proof-of-possession (PoP) request signer for the
|
|
4
|
+
[VAID](https://github.com/solara-associates/vaid) standard.
|
|
5
|
+
|
|
6
|
+
This is the **single Python definition** of the PoP signing contract. A
|
|
7
|
+
VAID-bound request carries a fresh, replay-protected Ed25519 signature; this
|
|
8
|
+
package builds the four `x-synthera-*` headers that carry it. Import it
|
|
9
|
+
directly — no extra runtime, no framework.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from vaid_pop import RequestSigner
|
|
13
|
+
|
|
14
|
+
signer = RequestSigner(vaid=vaid_doc, private_key=agent_key)
|
|
15
|
+
headers = signer.sign_headers("POST", "/vaid/mint", body_bytes)
|
|
16
|
+
# -> {"x-synthera-vaid": ..., "x-synthera-timestamp": ...,
|
|
17
|
+
# "x-synthera-nonce": ..., "x-synthera-signature": ...}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> The `x-synthera-*` header names are the VAID wire contract — the prefix is the
|
|
21
|
+
> fixed header namespace a conforming verifier reads, not a package dependency.
|
|
22
|
+
|
|
23
|
+
## The firewall
|
|
24
|
+
|
|
25
|
+
Cross-language byte-identity is the whole point. The signer is locked against the
|
|
26
|
+
frozen cross-language vector (vendored here at
|
|
27
|
+
`vaid_pop/vectors/operator_pop_v1.json`), which the Rust client (`vaid-client`)
|
|
28
|
+
and the verifier (`vaid_pop`) assert against too. The `pop-conformance` CI job
|
|
29
|
+
proves **Rust output == Python output == vector** byte-for-byte. A mismatch is a
|
|
30
|
+
hard blocker.
|
|
31
|
+
|
|
32
|
+
Contract: RFC 8785 (JCS) over the camelCase `RequestAuthPayload` → SHA-256 →
|
|
33
|
+
pure Ed25519 over the 32-byte digest → raw 64-byte signature.
|
|
34
|
+
|
|
35
|
+
> Standardized on `rfc8785` for JCS — the byte-for-byte JCS implementation the
|
|
36
|
+
> vector is proven against. Do not substitute another JCS library without
|
|
37
|
+
> re-proving byte-equality.
|
|
38
|
+
|
|
39
|
+
## Relationship to other packages
|
|
40
|
+
|
|
41
|
+
- **`vaid-client`** (Rust) is the cross-language peer, proven against the same vector.
|
|
42
|
+
- Language-specific agent integrations depend on this package and re-export `RequestSigner`.
|
|
43
|
+
- Clients consume this directly to authenticate a VAID-bound request.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vaid-pop"
|
|
7
|
+
# Independent semver. Additive-minor for new optional fields; major for any
|
|
8
|
+
# canonicalization / field-name change (those break cross-language byte-identity
|
|
9
|
+
# against operator_pop_v1.json).
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "Canonical Python proof-of-possession (PoP) request signer for the VAID standard."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
license = { text = "Apache-2.0" }
|
|
15
|
+
authors = [{ name = "solara.associates", email = "info@solara.associates" }]
|
|
16
|
+
keywords = ["vaid", "agent-identity", "ed25519", "proof-of-possession", "cryptography", "signing"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
]
|
|
25
|
+
# cryptography = Ed25519 (no stdlib option); rfc8785 = RFC 8785 JCS, spec-for-spec
|
|
26
|
+
# identical to the Rust serde_jcs path so the canonical bytes agree by construction
|
|
27
|
+
# (locked by the vendored operator_pop_v1.json). NOTE: standardized on rfc8785 — do
|
|
28
|
+
# NOT substitute another JCS library without re-proving byte-equality against the
|
|
29
|
+
# frozen vector.
|
|
30
|
+
dependencies = [
|
|
31
|
+
"cryptography>=42.0",
|
|
32
|
+
"rfc8785>=0.1.2",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
# The packaged firewall: an external consumer who installed ONLY the wheel can run
|
|
37
|
+
# `vaid-pop-conformance` to prove the signer they got reproduces the frozen
|
|
38
|
+
# cross-language vector byte-for-byte. Ships in the wheel (it's a package module),
|
|
39
|
+
# unlike the repo `tests/` suite.
|
|
40
|
+
vaid-pop-conformance = "vaid_pop.conformance:main"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/solara-associates/vaid"
|
|
44
|
+
Repository = "https://github.com/solara-associates/vaid"
|
|
45
|
+
Source = "https://github.com/solara-associates/vaid/tree/main/python/vaid-pop"
|
|
46
|
+
Issues = "https://github.com/solara-associates/vaid/issues"
|
|
47
|
+
|
|
48
|
+
[project.optional-dependencies]
|
|
49
|
+
test = [
|
|
50
|
+
"pytest>=8.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
# The vendored conformance vector ships inside the wheel so a consumer's CI runs
|
|
55
|
+
# the firewall against the exact bytes this signer was proven against.
|
|
56
|
+
packages = ["vaid_pop"]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = ["vaid_pop", "tests", "README.md"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Completion-record conformance gate (Python side of the cross-language firewall).
|
|
2
|
+
|
|
3
|
+
The vendored vector ``vaid_pop/vectors/completion_v1.json`` is byte-identical to
|
|
4
|
+
the Rust copy (a CI drift-check enforces that). Asserts the Python signer
|
|
5
|
+
reproduces the frozen digest + signature for a real CompletionRecord, and — since
|
|
6
|
+
this is the FIRST vector with an ENUM — that every AssuranceTier serializes to
|
|
7
|
+
exactly the frozen string (the most likely place for silent Rust/Python drift).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey,
|
|
17
|
+
Ed25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from vaid_pop import (
|
|
21
|
+
AssuranceTier,
|
|
22
|
+
build_completion_record,
|
|
23
|
+
canonical_request_signing_bytes,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _vector() -> dict:
|
|
28
|
+
data = files("vaid_pop").joinpath("vectors/completion_v1.json").read_text()
|
|
29
|
+
return json.loads(data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _record_from_input(inp: dict) -> dict:
|
|
33
|
+
# Rebuild through the package builder (not the raw vector dict) so the test
|
|
34
|
+
# exercises the real construction path, then assert it equals the frozen input.
|
|
35
|
+
return build_completion_record(
|
|
36
|
+
vaid_id=inp["vaidId"],
|
|
37
|
+
request_digest_sha256=inp["requestDigestSha256"],
|
|
38
|
+
tenant_id=inp["tenantId"],
|
|
39
|
+
status=inp["status"],
|
|
40
|
+
result_sha256=inp["resultSha256"],
|
|
41
|
+
completed_at=inp["completedAt"],
|
|
42
|
+
signer_vaid_id=inp["signerVaidId"],
|
|
43
|
+
assurance_tier=inp["assuranceTier"],
|
|
44
|
+
record_nonce=inp["recordNonce"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_completion_digest_matches_frozen_vector():
|
|
49
|
+
v = _vector()
|
|
50
|
+
record = _record_from_input(v["input"])
|
|
51
|
+
assert record == v["input"], "builder output must equal the frozen input dict"
|
|
52
|
+
digest = canonical_request_signing_bytes(record)
|
|
53
|
+
assert digest.hex() == v["digest_sha256_hex"], (
|
|
54
|
+
"Python completion-record digest diverged from the frozen vector — BLOCKER"
|
|
55
|
+
)
|
|
56
|
+
assert len(digest) == 32
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_completion_signature_matches_frozen_vector():
|
|
60
|
+
v = _vector()
|
|
61
|
+
seed = bytes.fromhex(v["ed25519"]["private_key_seed_hex"])
|
|
62
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
63
|
+
|
|
64
|
+
pub = sk.public_key().public_bytes_raw()
|
|
65
|
+
assert pub.hex() == v["ed25519"]["public_key_hex"], "public key diverged — BLOCKER"
|
|
66
|
+
|
|
67
|
+
digest = canonical_request_signing_bytes(_record_from_input(v["input"]))
|
|
68
|
+
sig = sk.sign(digest)
|
|
69
|
+
assert sig.hex() == v["ed25519"]["signature_hex"], "signature diverged — BLOCKER"
|
|
70
|
+
assert len(sig) == 64
|
|
71
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest) # raises on failure
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_assurance_tier_strings_match_frozen_vector():
|
|
75
|
+
"""THE ENUM DRIFT GUARD: the Python AssuranceTier values must equal the frozen
|
|
76
|
+
strings, in order — the field where Rust camelCase serde and Python enum values
|
|
77
|
+
must agree byte-for-byte inside the signed document."""
|
|
78
|
+
v = _vector()
|
|
79
|
+
frozen = v["assurance_tier_strings"]
|
|
80
|
+
py_values = [
|
|
81
|
+
AssuranceTier.SELF_REPORTED.value,
|
|
82
|
+
AssuranceTier.COUNTER_SIGNED.value,
|
|
83
|
+
AssuranceTier.THIRD_PARTY_ATTESTED.value,
|
|
84
|
+
]
|
|
85
|
+
assert py_values == frozen, "AssuranceTier strings diverged from the frozen vector — BLOCKER"
|
|
86
|
+
# The declared tier in `input` is the only substantiated tier.
|
|
87
|
+
assert v["input"]["assuranceTier"] == AssuranceTier.SELF_REPORTED.value
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Canonical PoP conformance gate (Python side of the cross-language firewall).
|
|
2
|
+
|
|
3
|
+
The vendored vector ``vaid_pop/vectors/operator_pop_v1.json`` is byte-identical
|
|
4
|
+
to the canonical cross-language vector in the ``vaid`` repo (a CI drift-check
|
|
5
|
+
enforces that). These tests assert the Python signer reproduces the frozen
|
|
6
|
+
digest + Ed25519 signature byte-for-byte; a mismatch is a BLOCKER, not a
|
|
7
|
+
ship-anyway. The Rust client (`vaid-client`) asserts the same vector, and the
|
|
8
|
+
repo-level ``pop-conformance`` job proves Rust output == Python output == vector.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from importlib.resources import files
|
|
18
|
+
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
20
|
+
Ed25519PrivateKey,
|
|
21
|
+
Ed25519PublicKey,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from vaid_pop import (
|
|
25
|
+
HEADER_NONCE,
|
|
26
|
+
HEADER_SIGNATURE,
|
|
27
|
+
HEADER_TIMESTAMP,
|
|
28
|
+
HEADER_VAID,
|
|
29
|
+
RequestSigner,
|
|
30
|
+
canonical_request_signing_bytes,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _vector() -> dict:
|
|
35
|
+
# Read the vector the package SHIPS — the firewall is only real if a consumer
|
|
36
|
+
# runs it against the copy bundled with the signer it actually installed.
|
|
37
|
+
data = files("vaid_pop").joinpath("vectors/operator_pop_v1.json").read_text()
|
|
38
|
+
return json.loads(data)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_canonical_digest_matches_frozen_vector():
|
|
42
|
+
"""Python JCS + SHA-256 over the camelCase RequestAuthPayload reproduces the
|
|
43
|
+
Rust-frozen digest byte-for-byte."""
|
|
44
|
+
v = _vector()
|
|
45
|
+
digest = canonical_request_signing_bytes(v["input"])
|
|
46
|
+
assert digest.hex() == v["digest_sha256_hex"], (
|
|
47
|
+
"Python canonical digest diverged from the frozen vector — BLOCKER"
|
|
48
|
+
)
|
|
49
|
+
assert len(digest) == 32
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_deterministic_signature_matches_frozen_vector():
|
|
53
|
+
"""From the frozen seed, Python derives the same public key and produces the
|
|
54
|
+
same deterministic Ed25519 signature the Rust vector froze."""
|
|
55
|
+
v = _vector()
|
|
56
|
+
seed = bytes.fromhex(v["ed25519"]["private_key_seed_hex"])
|
|
57
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
58
|
+
|
|
59
|
+
pub = sk.public_key().public_bytes_raw()
|
|
60
|
+
assert pub.hex() == v["ed25519"]["public_key_hex"], "public key diverged — BLOCKER"
|
|
61
|
+
|
|
62
|
+
digest = canonical_request_signing_bytes(v["input"])
|
|
63
|
+
sig = sk.sign(digest)
|
|
64
|
+
assert sig.hex() == v["ed25519"]["signature_hex"], "signature diverged — BLOCKER"
|
|
65
|
+
assert len(sig) == 64
|
|
66
|
+
|
|
67
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest) # raises on failure
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_request_signer_produces_verifiable_pop_headers():
|
|
71
|
+
"""The real signer path: RequestSigner.sign_headers builds the camelCase
|
|
72
|
+
RequestAuthPayload, signs it, and X-Synthera-Signature verifies against the
|
|
73
|
+
agent key over the reconstructed digest."""
|
|
74
|
+
v = _vector()
|
|
75
|
+
seed = bytes.fromhex(v["ed25519"]["private_key_seed_hex"])
|
|
76
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
77
|
+
pub = sk.public_key()
|
|
78
|
+
|
|
79
|
+
vaid = {
|
|
80
|
+
"vaid_id": v["input"]["vaidId"],
|
|
81
|
+
"tenant_id": v["input"]["tenantId"],
|
|
82
|
+
"agent_id": "22222222-2222-2222-2222-222222222222",
|
|
83
|
+
}
|
|
84
|
+
signer = RequestSigner(vaid=vaid, private_key=sk)
|
|
85
|
+
|
|
86
|
+
fixed_now = datetime(2026, 6, 4, 12, 0, 0, tzinfo=timezone.utc)
|
|
87
|
+
body = b"" # vector bodySha256 = sha256("") = e3b0c442...
|
|
88
|
+
headers = signer.sign_headers(
|
|
89
|
+
"POST", "/vaid/mint", body, now=fixed_now, nonce=v["input"]["clientNonce"]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert headers[HEADER_TIMESTAMP] == v["input"]["timestamp"]
|
|
93
|
+
assert headers[HEADER_NONCE] == v["input"]["clientNonce"]
|
|
94
|
+
|
|
95
|
+
payload = {
|
|
96
|
+
"vaidId": vaid["vaid_id"],
|
|
97
|
+
"method": "POST",
|
|
98
|
+
"path": "/vaid/mint",
|
|
99
|
+
"bodySha256": hashlib.sha256(body).hexdigest(),
|
|
100
|
+
"tenantId": vaid["tenant_id"],
|
|
101
|
+
"timestamp": headers[HEADER_TIMESTAMP],
|
|
102
|
+
"clientNonce": headers[HEADER_NONCE],
|
|
103
|
+
}
|
|
104
|
+
digest = canonical_request_signing_bytes(payload)
|
|
105
|
+
# The signer must reproduce the frozen digest AND signature for this input.
|
|
106
|
+
assert digest.hex() == v["digest_sha256_hex"]
|
|
107
|
+
sig = base64.b64decode(headers[HEADER_SIGNATURE])
|
|
108
|
+
assert sig.hex() == v["ed25519"]["signature_hex"], "header signature diverged — BLOCKER"
|
|
109
|
+
assert len(sig) == 64
|
|
110
|
+
pub.verify(sig, digest) # raises on failure → the header signature is valid
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""vaid-pop — canonical Python proof-of-possession request signer.
|
|
2
|
+
|
|
3
|
+
The single Python definition of the VAID PoP signing contract. Import this
|
|
4
|
+
package directly to sign a VAID-bound request without pulling in a framework
|
|
5
|
+
(language-specific agent integrations depend on this package and re-export the
|
|
6
|
+
signer). Byte-identity with the Rust client (`vaid-client`) and the verifier
|
|
7
|
+
(`vaid_pop`) is locked by the vendored cross-language vector
|
|
8
|
+
``vaid_pop/vectors/operator_pop_v1.json``.
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from vaid_pop import RequestSigner
|
|
13
|
+
|
|
14
|
+
signer = RequestSigner(vaid=vaid_doc, private_key=agent_key)
|
|
15
|
+
headers = signer.sign_headers("POST", "/vaid/mint", body_bytes)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from vaid_pop.completion import AssuranceTier, build_completion_record
|
|
19
|
+
from vaid_pop.signer import (
|
|
20
|
+
HEADER_NONCE,
|
|
21
|
+
HEADER_SIGNATURE,
|
|
22
|
+
HEADER_TIMESTAMP,
|
|
23
|
+
HEADER_VAID,
|
|
24
|
+
RequestSigner,
|
|
25
|
+
build_request_auth_payload,
|
|
26
|
+
canonical_request_signing_bytes,
|
|
27
|
+
utc_whole_second_rfc3339,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"RequestSigner",
|
|
32
|
+
"canonical_request_signing_bytes",
|
|
33
|
+
"build_request_auth_payload",
|
|
34
|
+
"utc_whole_second_rfc3339",
|
|
35
|
+
"AssuranceTier",
|
|
36
|
+
"build_completion_record",
|
|
37
|
+
"HEADER_VAID",
|
|
38
|
+
"HEADER_TIMESTAMP",
|
|
39
|
+
"HEADER_NONCE",
|
|
40
|
+
"HEADER_SIGNATURE",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Completion / provenance record — Python mirror of the Rust
|
|
2
|
+
``vaid_pop::request_completion``.
|
|
3
|
+
|
|
4
|
+
A signed statement that a VAID-authorized action finished, over the SAME
|
|
5
|
+
JCS→SHA-256→Ed25519 pipeline as the request PoP (no new crypto). Byte-identity
|
|
6
|
+
with the Rust ``CompletionRecord`` is locked by the shared vector
|
|
7
|
+
``completion_v1.json``.
|
|
8
|
+
|
|
9
|
+
SCOPE — self-signed, DECLARED metadata only
|
|
10
|
+
-------------------------------------------
|
|
11
|
+
The record carries one detached signature by ``signer_vaid_id``. That proves only
|
|
12
|
+
that this signer signed the record. The ``assurance_tier`` is therefore
|
|
13
|
+
**declared, not proven**:
|
|
14
|
+
|
|
15
|
+
* ``AssuranceTier.SELF_REPORTED`` is the only tier this repo substantiates on its
|
|
16
|
+
own (actor signs its own outcome; the signature verifies against the actor key).
|
|
17
|
+
* ``COUNTER_SIGNED`` / ``THIRD_PARTY_ATTESTED`` are NOT independently verifiable
|
|
18
|
+
from this repo alone — a self-reporting signer can set either and the single
|
|
19
|
+
signature still verifies. Provable counter-signing / third-party attestation is
|
|
20
|
+
a separate, not-yet-built primitive and is OUT OF SCOPE here.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from enum import Enum
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AssuranceTier(str, Enum):
|
|
29
|
+
"""Declared assurance level. String values mirror the Rust enum's
|
|
30
|
+
``rename_all = "camelCase"`` serialization EXACTLY — these strings go into the
|
|
31
|
+
signed document, so any divergence breaks cross-language byte-identity (this is
|
|
32
|
+
the enum-drift risk the conformance vector guards)."""
|
|
33
|
+
|
|
34
|
+
SELF_REPORTED = "selfReported"
|
|
35
|
+
COUNTER_SIGNED = "counterSigned"
|
|
36
|
+
THIRD_PARTY_ATTESTED = "thirdPartyAttested"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_completion_record(
|
|
40
|
+
*,
|
|
41
|
+
vaid_id: str,
|
|
42
|
+
request_digest_sha256: str,
|
|
43
|
+
tenant_id: str,
|
|
44
|
+
status: str,
|
|
45
|
+
result_sha256: str,
|
|
46
|
+
completed_at: str,
|
|
47
|
+
signer_vaid_id: str,
|
|
48
|
+
assurance_tier: AssuranceTier | str,
|
|
49
|
+
record_nonce: str,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""Assemble the camelCase ``CompletionRecord`` a completer signs.
|
|
52
|
+
|
|
53
|
+
Field names/encoding mirror the Rust ``CompletionRecord`` serde exactly. The
|
|
54
|
+
result is a plain ``dict`` (like a ``RequestAuthPayload``) ready for
|
|
55
|
+
``canonical_request_signing_bytes`` → ``sign``. ``assurance_tier`` accepts the
|
|
56
|
+
:class:`AssuranceTier` enum or its string value; it is written as the string.
|
|
57
|
+
|
|
58
|
+
``completed_at`` MUST be whole-second RFC 3339 ``…Z`` (the same fixed point as
|
|
59
|
+
``RequestAuthPayload.timestamp``) or byte-identity breaks.
|
|
60
|
+
"""
|
|
61
|
+
tier = assurance_tier.value if isinstance(assurance_tier, AssuranceTier) else assurance_tier
|
|
62
|
+
return {
|
|
63
|
+
"vaidId": vaid_id,
|
|
64
|
+
"requestDigestSha256": request_digest_sha256,
|
|
65
|
+
"tenantId": tenant_id,
|
|
66
|
+
"status": status,
|
|
67
|
+
"resultSha256": result_sha256,
|
|
68
|
+
"completedAt": completed_at,
|
|
69
|
+
"signerVaidId": signer_vaid_id,
|
|
70
|
+
"assuranceTier": tier,
|
|
71
|
+
"recordNonce": record_nonce,
|
|
72
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Packaged cross-language PoP conformance check — the firewall, shipped in the wheel.
|
|
2
|
+
|
|
3
|
+
This module is the externally-consumable form of the conformance gate. Unlike the
|
|
4
|
+
repo test suite under ``tests/`` (which does not ship in the wheel), this lives
|
|
5
|
+
*inside* the installed package, so a consumer who has only ``pip install
|
|
6
|
+
vaid-pop`` — no repo checkout — can prove the signer they installed reproduces
|
|
7
|
+
the frozen cross-language vector byte-for-byte:
|
|
8
|
+
|
|
9
|
+
python -m vaid_pop.conformance # exit 0 = PASS, 1 = BLOCKER
|
|
10
|
+
vaid-pop-conformance # same, via the console entry point
|
|
11
|
+
|
|
12
|
+
Programmatically::
|
|
13
|
+
|
|
14
|
+
from vaid_pop.conformance import run
|
|
15
|
+
run() # raises ConformanceError on any divergence; returns the vector on PASS
|
|
16
|
+
|
|
17
|
+
The vector it checks against is the one bundled with the signer
|
|
18
|
+
(``vaid_pop/vectors/operator_pop_v1.json``) — the firewall is only real if a
|
|
19
|
+
consumer runs it against the exact bytes the installed signer was proven against.
|
|
20
|
+
The Rust client (``vaid-client``) asserts the identical vector; the repo-level
|
|
21
|
+
``pop-conformance`` job proves Rust output == Python output == vector.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
from importlib.resources import files
|
|
30
|
+
|
|
31
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
32
|
+
Ed25519PrivateKey,
|
|
33
|
+
Ed25519PublicKey,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from vaid_pop import (
|
|
37
|
+
HEADER_NONCE,
|
|
38
|
+
HEADER_SIGNATURE,
|
|
39
|
+
HEADER_TIMESTAMP,
|
|
40
|
+
RequestSigner,
|
|
41
|
+
canonical_request_signing_bytes,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConformanceError(AssertionError):
|
|
46
|
+
"""A cross-language byte-identity divergence — a hard BLOCKER, never ship-anyway."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_vector() -> dict:
|
|
50
|
+
"""The operator-PoP conformance vector bundled with the installed package."""
|
|
51
|
+
data = files("vaid_pop").joinpath("vectors/operator_pop_v1.json").read_text()
|
|
52
|
+
return json.loads(data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_completion_vector() -> dict:
|
|
56
|
+
"""The completion-record conformance vector bundled with the installed package."""
|
|
57
|
+
data = files("vaid_pop").joinpath("vectors/completion_v1.json").read_text()
|
|
58
|
+
return json.loads(data)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_completion(v: dict) -> None:
|
|
62
|
+
"""Python JCS + SHA-256 + Ed25519 over the camelCase CompletionRecord reproduce
|
|
63
|
+
the frozen digest + signature, and the AssuranceTier strings match (the enum
|
|
64
|
+
drift guard — the first vector with an enum)."""
|
|
65
|
+
from vaid_pop import AssuranceTier
|
|
66
|
+
|
|
67
|
+
digest = canonical_request_signing_bytes(v["input"])
|
|
68
|
+
if digest.hex() != v["digest_sha256_hex"]:
|
|
69
|
+
raise ConformanceError(
|
|
70
|
+
f"completion digest diverged from the frozen vector — BLOCKER\n"
|
|
71
|
+
f" got = {digest.hex()}\n vector = {v['digest_sha256_hex']}"
|
|
72
|
+
)
|
|
73
|
+
seed = bytes.fromhex(v["ed25519"]["private_key_seed_hex"])
|
|
74
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
75
|
+
sig = sk.sign(digest)
|
|
76
|
+
if sig.hex() != v["ed25519"]["signature_hex"]:
|
|
77
|
+
raise ConformanceError(
|
|
78
|
+
f"completion signature diverged from the frozen vector — BLOCKER\n"
|
|
79
|
+
f" got = {sig.hex()}\n vector = {v['ed25519']['signature_hex']}"
|
|
80
|
+
)
|
|
81
|
+
py_tiers = [
|
|
82
|
+
AssuranceTier.SELF_REPORTED.value,
|
|
83
|
+
AssuranceTier.COUNTER_SIGNED.value,
|
|
84
|
+
AssuranceTier.THIRD_PARTY_ATTESTED.value,
|
|
85
|
+
]
|
|
86
|
+
if py_tiers != v["assurance_tier_strings"]:
|
|
87
|
+
raise ConformanceError(
|
|
88
|
+
f"AssuranceTier strings diverged from the frozen vector — BLOCKER\n"
|
|
89
|
+
f" got = {py_tiers}\n vector = {v['assurance_tier_strings']}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_digest(v: dict) -> None:
|
|
94
|
+
"""Python JCS + SHA-256 over the camelCase RequestAuthPayload == frozen digest."""
|
|
95
|
+
digest = canonical_request_signing_bytes(v["input"])
|
|
96
|
+
if digest.hex() != v["digest_sha256_hex"]:
|
|
97
|
+
raise ConformanceError(
|
|
98
|
+
f"canonical digest diverged from the frozen vector — BLOCKER\n"
|
|
99
|
+
f" got = {digest.hex()}\n vector = {v['digest_sha256_hex']}"
|
|
100
|
+
)
|
|
101
|
+
if len(digest) != 32:
|
|
102
|
+
raise ConformanceError(f"digest is {len(digest)} bytes, expected 32")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_signature(v: dict) -> None:
|
|
106
|
+
"""From the frozen seed, derive the same public key + deterministic signature."""
|
|
107
|
+
seed = bytes.fromhex(v["ed25519"]["private_key_seed_hex"])
|
|
108
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
109
|
+
|
|
110
|
+
pub = sk.public_key().public_bytes_raw()
|
|
111
|
+
if pub.hex() != v["ed25519"]["public_key_hex"]:
|
|
112
|
+
raise ConformanceError(
|
|
113
|
+
f"public key diverged — BLOCKER\n"
|
|
114
|
+
f" got = {pub.hex()}\n vector = {v['ed25519']['public_key_hex']}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
digest = canonical_request_signing_bytes(v["input"])
|
|
118
|
+
sig = sk.sign(digest)
|
|
119
|
+
if sig.hex() != v["ed25519"]["signature_hex"]:
|
|
120
|
+
raise ConformanceError(
|
|
121
|
+
f"signature diverged — BLOCKER\n"
|
|
122
|
+
f" got = {sig.hex()}\n vector = {v['ed25519']['signature_hex']}"
|
|
123
|
+
)
|
|
124
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest) # raises on failure
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_request_signer(v: dict) -> None:
|
|
128
|
+
"""The real signer path: RequestSigner.sign_headers produces the frozen signature
|
|
129
|
+
and a header that verifies against the agent key over the reconstructed digest."""
|
|
130
|
+
from datetime import datetime, timezone
|
|
131
|
+
|
|
132
|
+
seed = bytes.fromhex(v["ed25519"]["private_key_seed_hex"])
|
|
133
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
134
|
+
pub = sk.public_key()
|
|
135
|
+
|
|
136
|
+
inp = v["input"]
|
|
137
|
+
vaid = {"vaid_id": inp["vaidId"], "tenant_id": inp["tenantId"]}
|
|
138
|
+
signer = RequestSigner(vaid=vaid, private_key=sk)
|
|
139
|
+
|
|
140
|
+
now = datetime.strptime(inp["timestamp"], "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
141
|
+
tzinfo=timezone.utc
|
|
142
|
+
)
|
|
143
|
+
body = b"" # vector bodySha256 = sha256("")
|
|
144
|
+
headers = signer.sign_headers(
|
|
145
|
+
inp["method"], inp["path"], body, now=now, nonce=inp["clientNonce"]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if headers[HEADER_TIMESTAMP] != inp["timestamp"]:
|
|
149
|
+
raise ConformanceError("header timestamp diverged — BLOCKER")
|
|
150
|
+
if headers[HEADER_NONCE] != inp["clientNonce"]:
|
|
151
|
+
raise ConformanceError("header nonce diverged — BLOCKER")
|
|
152
|
+
|
|
153
|
+
payload = {
|
|
154
|
+
"vaidId": vaid["vaid_id"],
|
|
155
|
+
"method": "POST",
|
|
156
|
+
"path": "/vaid/mint",
|
|
157
|
+
"bodySha256": hashlib.sha256(body).hexdigest(),
|
|
158
|
+
"tenantId": vaid["tenant_id"],
|
|
159
|
+
"timestamp": headers[HEADER_TIMESTAMP],
|
|
160
|
+
"clientNonce": headers[HEADER_NONCE],
|
|
161
|
+
}
|
|
162
|
+
digest = canonical_request_signing_bytes(payload)
|
|
163
|
+
sig = base64.b64decode(headers[HEADER_SIGNATURE])
|
|
164
|
+
if sig.hex() != v["ed25519"]["signature_hex"]:
|
|
165
|
+
raise ConformanceError("header signature diverged — BLOCKER")
|
|
166
|
+
pub.verify(sig, digest) # raises on failure → header signature is valid
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run() -> dict:
|
|
170
|
+
"""Run all firewall checks against the bundled vector. Raises ConformanceError
|
|
171
|
+
on any divergence; returns the vector on PASS."""
|
|
172
|
+
v = load_vector()
|
|
173
|
+
check_digest(v)
|
|
174
|
+
check_signature(v)
|
|
175
|
+
check_request_signer(v)
|
|
176
|
+
check_completion(load_completion_vector())
|
|
177
|
+
return v
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- pytest discovery (so `pytest --pyargs vaid_pop` runs the firewall) ---
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_packaged_digest_matches_frozen_vector() -> None:
|
|
184
|
+
check_digest(load_vector())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_packaged_signature_matches_frozen_vector() -> None:
|
|
188
|
+
check_signature(load_vector())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_packaged_request_signer_matches_frozen_vector() -> None:
|
|
192
|
+
check_request_signer(load_vector())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_packaged_completion_matches_frozen_vector() -> None:
|
|
196
|
+
check_completion(load_completion_vector())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main() -> int:
|
|
200
|
+
try:
|
|
201
|
+
v = run()
|
|
202
|
+
except ConformanceError as exc:
|
|
203
|
+
print(f"CROSS-LANGUAGE PoP FIREWALL: MISMATCH — BLOCKER\n{exc}")
|
|
204
|
+
return 1
|
|
205
|
+
c = load_completion_vector()
|
|
206
|
+
print(
|
|
207
|
+
"CROSS-LANGUAGE PoP FIREWALL: PASS — installed signer == frozen vectors, "
|
|
208
|
+
"byte-for-byte\n"
|
|
209
|
+
f" operator digest = {v['digest_sha256_hex']}\n"
|
|
210
|
+
f" operator signature = {v['ed25519']['signature_hex']}\n"
|
|
211
|
+
f" completion digest = {c['digest_sha256_hex']}\n"
|
|
212
|
+
f" completion signature = {c['ed25519']['signature_hex']}"
|
|
213
|
+
)
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""VAID proof-of-possession request signing — canonical Python signer.
|
|
2
|
+
|
|
3
|
+
The Rust `vaid-pop` crate (the operator-signing port and the `vaid_pop`
|
|
4
|
+
verifier) defines the CANONICAL contract; this is the Python **mirror**, not a
|
|
5
|
+
second definition. Byte-identity is locked by the shared cross-language vector
|
|
6
|
+
``operator_pop_v1.json`` (vendored into this package at ``vaid_pop/vectors/`` and
|
|
7
|
+
drift-checked against the canonical source): if these bytes ever diverge from
|
|
8
|
+
Rust, the conformance test is a BLOCKER.
|
|
9
|
+
|
|
10
|
+
This module is the single source of the Python PoP signer. Language-specific
|
|
11
|
+
agent integrations DEPEND ON this package and re-export ``RequestSigner`` /
|
|
12
|
+
``canonical_request_signing_bytes`` — they no longer carry their own copy. Any
|
|
13
|
+
framework-free consumer imports ``vaid_pop`` directly and pulls in nothing else.
|
|
14
|
+
|
|
15
|
+
Contract: RFC 8785 (JCS) over the camelCase ``RequestAuthPayload`` -> SHA-256 ->
|
|
16
|
+
32-byte digest -> **pure Ed25519 over the digest as the raw message** -> raw
|
|
17
|
+
64-byte signature. The four headers below carry it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import secrets
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
|
|
28
|
+
import rfc8785
|
|
29
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
30
|
+
|
|
31
|
+
# VAID PoP wire headers — the fixed `x-synthera-*` header namespace a conforming
|
|
32
|
+
# verifier reads (the wire contract; the prefix is not a package dependency).
|
|
33
|
+
HEADER_VAID = "x-synthera-vaid"
|
|
34
|
+
HEADER_TIMESTAMP = "x-synthera-timestamp"
|
|
35
|
+
HEADER_NONCE = "x-synthera-nonce"
|
|
36
|
+
HEADER_SIGNATURE = "x-synthera-signature"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def canonical_request_signing_bytes(payload: dict) -> bytes:
|
|
40
|
+
"""The 32-byte signing digest: RFC 8785 (JCS) of `payload`, then SHA-256.
|
|
41
|
+
|
|
42
|
+
Mirror of the Rust `vaid_pop::canonical_request_signing_bytes`. The
|
|
43
|
+
payload MUST use the camelCase `RequestAuthPayload` field names
|
|
44
|
+
(`vaidId`, `method`, `path`, `bodySha256`, `tenantId`, `timestamp`,
|
|
45
|
+
`clientNonce`) — JCS sorts keys, so order is irrelevant, but names are not.
|
|
46
|
+
"""
|
|
47
|
+
return hashlib.sha256(rfc8785.dumps(payload)).digest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_request_auth_payload(
|
|
51
|
+
*,
|
|
52
|
+
vaid_id: str,
|
|
53
|
+
method: str,
|
|
54
|
+
path: str,
|
|
55
|
+
body_sha256: str,
|
|
56
|
+
tenant_id: str,
|
|
57
|
+
timestamp: str,
|
|
58
|
+
client_nonce: str,
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""The seven ADR-mandated fields, camelCase (mirrors `RequestAuthPayload`)."""
|
|
61
|
+
return {
|
|
62
|
+
"vaidId": vaid_id,
|
|
63
|
+
"method": method,
|
|
64
|
+
"path": path,
|
|
65
|
+
"bodySha256": body_sha256,
|
|
66
|
+
"tenantId": tenant_id,
|
|
67
|
+
"timestamp": timestamp,
|
|
68
|
+
"clientNonce": client_nonce,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def utc_whole_second_rfc3339(now: datetime | None = None) -> str:
|
|
73
|
+
"""Whole-second UTC RFC 3339 with `Z`.
|
|
74
|
+
|
|
75
|
+
This is the chrono-serde fixed point: the verifier parses the
|
|
76
|
+
`x-synthera-timestamp` header into a `DateTime<Utc>` and re-serializes it
|
|
77
|
+
when it recomputes the canonical bytes; a whole-second `...Z` string parses
|
|
78
|
+
and re-serializes to itself, so the client's signed-payload timestamp matches
|
|
79
|
+
the server's. Sub-second precision would risk a re-serialization mismatch.
|
|
80
|
+
"""
|
|
81
|
+
now = now or datetime.now(timezone.utc)
|
|
82
|
+
return now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RequestSigner:
|
|
86
|
+
"""Signs requests with the agent's per-agent Ed25519 key (build-3 PoP).
|
|
87
|
+
|
|
88
|
+
The agent's VAID document (as minted) and its private key are INPUTS here;
|
|
89
|
+
durable provisioning/injection of the key is the agent template's job,
|
|
90
|
+
not this client's.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, *, vaid: dict, private_key: Ed25519PrivateKey) -> None:
|
|
94
|
+
# The VAID document is snake_case (`Vaid` has no serde rename), unlike
|
|
95
|
+
# the camelCase `RequestAuthPayload`. Extract identity by snake_case keys.
|
|
96
|
+
try:
|
|
97
|
+
self._vaid_id = str(vaid["vaid_id"])
|
|
98
|
+
self._tenant_id = str(vaid["tenant_id"])
|
|
99
|
+
except KeyError as exc:
|
|
100
|
+
raise ValueError(f"VAID document missing required field {exc}") from exc
|
|
101
|
+
self._private_key = private_key
|
|
102
|
+
# X-Synthera-Vaid = base64 of the VAID document JSON. The verifier
|
|
103
|
+
# re-deserializes by field and recomputes the VAID's own canonical bytes
|
|
104
|
+
# for signature verification, so a compact re-encoding is fine.
|
|
105
|
+
self._vaid_header = base64.b64encode(
|
|
106
|
+
json.dumps(vaid, separators=(",", ":")).encode("utf-8")
|
|
107
|
+
).decode("ascii")
|
|
108
|
+
|
|
109
|
+
def sign_headers(
|
|
110
|
+
self,
|
|
111
|
+
method: str,
|
|
112
|
+
path: str,
|
|
113
|
+
body: bytes,
|
|
114
|
+
*,
|
|
115
|
+
now: datetime | None = None,
|
|
116
|
+
nonce: str | None = None,
|
|
117
|
+
) -> dict[str, str]:
|
|
118
|
+
"""Produce the four PoP headers for `(method, path, body)`.
|
|
119
|
+
|
|
120
|
+
`now`/`nonce` are injectable for deterministic tests; in production they
|
|
121
|
+
default to the current UTC second and a fresh 128-bit random nonce.
|
|
122
|
+
"""
|
|
123
|
+
timestamp = utc_whole_second_rfc3339(now)
|
|
124
|
+
nonce = nonce or secrets.token_hex(16)
|
|
125
|
+
body_sha256 = hashlib.sha256(body).hexdigest()
|
|
126
|
+
payload = build_request_auth_payload(
|
|
127
|
+
vaid_id=self._vaid_id,
|
|
128
|
+
method=method.upper(),
|
|
129
|
+
path=path,
|
|
130
|
+
body_sha256=body_sha256,
|
|
131
|
+
tenant_id=self._tenant_id,
|
|
132
|
+
timestamp=timestamp,
|
|
133
|
+
client_nonce=nonce,
|
|
134
|
+
)
|
|
135
|
+
digest = canonical_request_signing_bytes(payload)
|
|
136
|
+
signature = self._private_key.sign(digest) # pure Ed25519, raw 64 bytes
|
|
137
|
+
return {
|
|
138
|
+
HEADER_VAID: self._vaid_header,
|
|
139
|
+
HEADER_TIMESTAMP: timestamp,
|
|
140
|
+
HEADER_NONCE: nonce,
|
|
141
|
+
HEADER_SIGNATURE: base64.b64encode(signature).decode("ascii"),
|
|
142
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Completion-record conformance vector (v1). Load-bearing. `input` is a real CompletionRecord (camelCase). NOTE: this is the FIRST vector containing an ENUM (`assuranceTier`) — the most likely place for silent Rust/Python string divergence, so an explicit enum round-trip test accompanies it. A conforming implementation MUST produce `digest_sha256_hex` from `input` via JCS (RFC 8785) -> SHA-256 and reproduce `ed25519.signature_hex` byte-for-byte. SCOPE: self-signed DECLARED metadata only — the single signature substantiates `assuranceTier=selfReported` and nothing above it; counterSigned / thirdPartyAttested are unverified claims here (a separate, not-yet-built primitive would substantiate them). Same JCS→SHA-256→Ed25519 primitive as operator_pop_v1.json.",
|
|
3
|
+
"assurance_tier_strings": [
|
|
4
|
+
"selfReported",
|
|
5
|
+
"counterSigned",
|
|
6
|
+
"thirdPartyAttested"
|
|
7
|
+
],
|
|
8
|
+
"digest_sha256_hex": "98a51b7e34cbfc8d57a58f7c8fd6e353ad50936ab9f26293a10374c755cd784d",
|
|
9
|
+
"ed25519": {
|
|
10
|
+
"_comment": "Deterministic test key (same RFC 8032 seed as operator_pop_v1.json).",
|
|
11
|
+
"private_key_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
12
|
+
"public_key_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
|
13
|
+
"signature_hex": "19657ec3ea3c660807e6153b66ff6e6d534c0aa798344614d162790564103be6a393eca15da2db52d0a7572f1d014a68834217c221895de46b8d322b2a3a150e"
|
|
14
|
+
},
|
|
15
|
+
"input": {
|
|
16
|
+
"assuranceTier": "selfReported",
|
|
17
|
+
"completedAt": "2026-06-04T12:00:05Z",
|
|
18
|
+
"recordNonce": "fedcba9876543210fedcba9876543210",
|
|
19
|
+
"requestDigestSha256": "ee474ba87d703ebeacf663d7d6a2f15319bdef285c5b702e336d0f4af5b61327",
|
|
20
|
+
"resultSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
21
|
+
"signerVaidId": "11111111-1111-1111-1111-111111111111",
|
|
22
|
+
"status": "succeeded",
|
|
23
|
+
"tenantId": "synthera-control-plane",
|
|
24
|
+
"vaidId": "11111111-1111-1111-1111-111111111111"
|
|
25
|
+
},
|
|
26
|
+
"scheme": "JCS(RFC8785) -> SHA-256 -> pure Ed25519 over the 32-byte digest as raw message; raw 64-byte signature; raw 32-byte Ed25519 public key"
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Operator-signing conformance vector (v1). Load-bearing. `input` is a real RequestAuthPayload (#[serde(rename_all=camelCase)]), not a hand-written generic object. A conforming verifier MUST produce `digest_sha256_hex` from `input` via JCS (RFC 8785) -> SHA-256, and (given `ed25519.private_key_seed_hex`) reproduce `ed25519.signature_hex` byte-for-byte (Ed25519 is deterministic, RFC 8032). A mismatch in any field name, ordering, encoding, or signature is a canonicalization break.",
|
|
3
|
+
"scheme": "JCS(RFC8785) -> SHA-256 -> pure Ed25519 over the 32-byte digest as raw message; raw 64-byte signature; raw 32-byte Ed25519 public key",
|
|
4
|
+
"input": {
|
|
5
|
+
"vaidId": "11111111-1111-1111-1111-111111111111",
|
|
6
|
+
"method": "POST",
|
|
7
|
+
"path": "/vaid/mint",
|
|
8
|
+
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
9
|
+
"tenantId": "synthera-control-plane",
|
|
10
|
+
"timestamp": "2026-06-04T12:00:00Z",
|
|
11
|
+
"clientNonce": "0123456789abcdef0123456789abcdef"
|
|
12
|
+
},
|
|
13
|
+
"digest_sha256_hex": "ee474ba87d703ebeacf663d7d6a2f15319bdef285c5b702e336d0f4af5b61327",
|
|
14
|
+
"ed25519": {
|
|
15
|
+
"_comment": "Deterministic test key (RFC 8032 Ed25519 private seed). Any conforming implementation derives the same public key and produces the same signature over digest_sha256_hex. The signature verifies under vaid_pop::verify_signed_payload.",
|
|
16
|
+
"private_key_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
17
|
+
"public_key_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
|
18
|
+
"signature_hex": "77e79744c362d352ce678992a3e3934fa57c33c3f307f6ffbe6ffc4ec5e726e096844fd8552db819b21a3e49e3b7796e23d1e10d4a03699285df4871f62e1502"
|
|
19
|
+
}
|
|
20
|
+
}
|