vaid-langchain 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_langchain-0.1.0/.gitignore +24 -0
- vaid_langchain-0.1.0/PKG-INFO +116 -0
- vaid_langchain-0.1.0/README.md +89 -0
- vaid_langchain-0.1.0/examples/quickstart.py +62 -0
- vaid_langchain-0.1.0/pyproject.toml +59 -0
- vaid_langchain-0.1.0/tests/test_langchain_tool.py +112 -0
- vaid_langchain-0.1.0/tests/test_path_convention.py +146 -0
- vaid_langchain-0.1.0/tests/test_vaid_auth.py +167 -0
- vaid_langchain-0.1.0/vaid_langchain/__init__.py +26 -0
- vaid_langchain-0.1.0/vaid_langchain/_target.py +43 -0
- vaid_langchain-0.1.0/vaid_langchain/auth.py +55 -0
- vaid_langchain-0.1.0/vaid_langchain/conformance.py +191 -0
- vaid_langchain-0.1.0/vaid_langchain/langchain_tool.py +72 -0
- vaid_langchain-0.1.0/vaid_langchain/vectors/pathquery_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/
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vaid-langchain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain request-signing adapter for the VAID standard: sign every outbound agent HTTP request with a VAID proof-of-possession via a thin httpx.Auth seam.
|
|
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-langchain
|
|
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: agents,httpx,langchain,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: httpx>=0.27
|
|
20
|
+
Requires-Dist: vaid-pop>=0.1.0
|
|
21
|
+
Provides-Extra: langchain
|
|
22
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: langchain-core>=0.3; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# vaid-langchain
|
|
29
|
+
|
|
30
|
+
Sign every outbound HTTP request a LangChain agent makes with a **VAID
|
|
31
|
+
proof-of-possession**, without forking LangChain.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
35
|
+
from vaid_pop import RequestSigner
|
|
36
|
+
from vaid_langchain import make_vaid_tool
|
|
37
|
+
|
|
38
|
+
signer = RequestSigner(vaid=my_vaid_doc, private_key=my_key) # hand-provisioned
|
|
39
|
+
tool = make_vaid_tool(signer, base_url="https://api.example.com")
|
|
40
|
+
# give `tool` to a LangChain agent — every call it makes to the backend is
|
|
41
|
+
# VAID-signed with the four x-synthera-* headers.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run the zero-dependency demo (mock backend, no server/LLM/API key):
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
python examples/quickstart.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## How it works — the seam is the HTTP client, not the tool layer
|
|
51
|
+
|
|
52
|
+
Agent frameworks hook at the *tool-call* layer (a function name + kwargs). VAID
|
|
53
|
+
proof-of-possession signs an *HTTP request* — it binds `(method, request-target,
|
|
54
|
+
body_sha256)`. So the clean, non-forking seam is the **HTTP client** the tool
|
|
55
|
+
uses: **`VaidAuth`** is an `httpx.Auth` subclass that runs on every outbound
|
|
56
|
+
request and attaches the PoP headers.
|
|
57
|
+
|
|
58
|
+
That means `VaidAuth` is the whole reusable adapter — usable with any `httpx`
|
|
59
|
+
client, LangChain not required:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import httpx
|
|
63
|
+
from vaid_langchain import VaidAuth
|
|
64
|
+
client = httpx.Client(base_url="https://api.example.com", auth=VaidAuth(signer))
|
|
65
|
+
client.post("/vaid/mint", json={...}) # signed
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`make_vaid_tool` is just LangChain's idiomatic wrapper (a `StructuredTool`) around
|
|
69
|
+
a client carrying that auth. Signing itself defers to `vaid_pop.RequestSigner` —
|
|
70
|
+
canonicalization is never reimplemented, so a conforming verifier derives
|
|
71
|
+
identical bytes.
|
|
72
|
+
|
|
73
|
+
## The signed request target includes the query string — a security decision
|
|
74
|
+
|
|
75
|
+
The signed `path` is the **on-the-wire request target: percent-encoded path +
|
|
76
|
+
`?query`** (httpx `request.url.raw_path`), never path-only.
|
|
77
|
+
|
|
78
|
+
This is a **security decision, not a demo convenience.** Signing path-only would
|
|
79
|
+
leave query parameters (`?tenant=…`, `?limit=…`, ids, filters) **outside the
|
|
80
|
+
signature**, so an attacker could alter them under a still-valid signature. Since
|
|
81
|
+
the query frequently carries authorization-relevant material, it must be part of
|
|
82
|
+
the signed target. The convention is pinned and covered by an explicit
|
|
83
|
+
cross-language round-trip test (`tests/test_path_convention.py`), including a case
|
|
84
|
+
proving that using the wrong attribute (query-dropping `.path`) fails verification
|
|
85
|
+
**loudly** rather than silently. A verifier MUST reconstruct the same target
|
|
86
|
+
(path + query) it received.
|
|
87
|
+
|
|
88
|
+
## Provisioning is hand-done — NOT managed by this SDK
|
|
89
|
+
|
|
90
|
+
This adapter signs requests; it does **not** provision identity. Getting the
|
|
91
|
+
agent's **VAID document** and its **Ed25519 private key** into the process is the
|
|
92
|
+
caller's responsibility:
|
|
93
|
+
|
|
94
|
+
- **Mint the VAID** with the reference mint (`vaid-mint`) or your deployment's
|
|
95
|
+
managed authority.
|
|
96
|
+
- **Load the private key** from your own secret store / env / file.
|
|
97
|
+
|
|
98
|
+
The quickstart hard-codes a demo VAID + key purely to run offline. Do not ship
|
|
99
|
+
hard-coded keys — treat the agent's private key (and, per the mint's delegation
|
|
100
|
+
model, any parent VAID it holds) as a credential.
|
|
101
|
+
|
|
102
|
+
## Install (local dev)
|
|
103
|
+
|
|
104
|
+
Depends on `vaid-pop`. For a local checkout:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
pip install -e python/vaid-pop
|
|
108
|
+
pip install -e python/vaid-langchain[test] # includes langchain-core + pytest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Other frameworks
|
|
112
|
+
|
|
113
|
+
CrewAI and Google ADK integrate through the **same `VaidAuth` seam** (subclass
|
|
114
|
+
`crewai.tools.BaseTool` / wrap an ADK `FunctionTool` over a `VaidAuth`-bearing
|
|
115
|
+
client). Only LangChain ships today; the shared auth is what makes the others
|
|
116
|
+
near-copies.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# vaid-langchain
|
|
2
|
+
|
|
3
|
+
Sign every outbound HTTP request a LangChain agent makes with a **VAID
|
|
4
|
+
proof-of-possession**, without forking LangChain.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
8
|
+
from vaid_pop import RequestSigner
|
|
9
|
+
from vaid_langchain import make_vaid_tool
|
|
10
|
+
|
|
11
|
+
signer = RequestSigner(vaid=my_vaid_doc, private_key=my_key) # hand-provisioned
|
|
12
|
+
tool = make_vaid_tool(signer, base_url="https://api.example.com")
|
|
13
|
+
# give `tool` to a LangChain agent — every call it makes to the backend is
|
|
14
|
+
# VAID-signed with the four x-synthera-* headers.
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Run the zero-dependency demo (mock backend, no server/LLM/API key):
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
python examples/quickstart.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## How it works — the seam is the HTTP client, not the tool layer
|
|
24
|
+
|
|
25
|
+
Agent frameworks hook at the *tool-call* layer (a function name + kwargs). VAID
|
|
26
|
+
proof-of-possession signs an *HTTP request* — it binds `(method, request-target,
|
|
27
|
+
body_sha256)`. So the clean, non-forking seam is the **HTTP client** the tool
|
|
28
|
+
uses: **`VaidAuth`** is an `httpx.Auth` subclass that runs on every outbound
|
|
29
|
+
request and attaches the PoP headers.
|
|
30
|
+
|
|
31
|
+
That means `VaidAuth` is the whole reusable adapter — usable with any `httpx`
|
|
32
|
+
client, LangChain not required:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import httpx
|
|
36
|
+
from vaid_langchain import VaidAuth
|
|
37
|
+
client = httpx.Client(base_url="https://api.example.com", auth=VaidAuth(signer))
|
|
38
|
+
client.post("/vaid/mint", json={...}) # signed
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`make_vaid_tool` is just LangChain's idiomatic wrapper (a `StructuredTool`) around
|
|
42
|
+
a client carrying that auth. Signing itself defers to `vaid_pop.RequestSigner` —
|
|
43
|
+
canonicalization is never reimplemented, so a conforming verifier derives
|
|
44
|
+
identical bytes.
|
|
45
|
+
|
|
46
|
+
## The signed request target includes the query string — a security decision
|
|
47
|
+
|
|
48
|
+
The signed `path` is the **on-the-wire request target: percent-encoded path +
|
|
49
|
+
`?query`** (httpx `request.url.raw_path`), never path-only.
|
|
50
|
+
|
|
51
|
+
This is a **security decision, not a demo convenience.** Signing path-only would
|
|
52
|
+
leave query parameters (`?tenant=…`, `?limit=…`, ids, filters) **outside the
|
|
53
|
+
signature**, so an attacker could alter them under a still-valid signature. Since
|
|
54
|
+
the query frequently carries authorization-relevant material, it must be part of
|
|
55
|
+
the signed target. The convention is pinned and covered by an explicit
|
|
56
|
+
cross-language round-trip test (`tests/test_path_convention.py`), including a case
|
|
57
|
+
proving that using the wrong attribute (query-dropping `.path`) fails verification
|
|
58
|
+
**loudly** rather than silently. A verifier MUST reconstruct the same target
|
|
59
|
+
(path + query) it received.
|
|
60
|
+
|
|
61
|
+
## Provisioning is hand-done — NOT managed by this SDK
|
|
62
|
+
|
|
63
|
+
This adapter signs requests; it does **not** provision identity. Getting the
|
|
64
|
+
agent's **VAID document** and its **Ed25519 private key** into the process is the
|
|
65
|
+
caller's responsibility:
|
|
66
|
+
|
|
67
|
+
- **Mint the VAID** with the reference mint (`vaid-mint`) or your deployment's
|
|
68
|
+
managed authority.
|
|
69
|
+
- **Load the private key** from your own secret store / env / file.
|
|
70
|
+
|
|
71
|
+
The quickstart hard-codes a demo VAID + key purely to run offline. Do not ship
|
|
72
|
+
hard-coded keys — treat the agent's private key (and, per the mint's delegation
|
|
73
|
+
model, any parent VAID it holds) as a credential.
|
|
74
|
+
|
|
75
|
+
## Install (local dev)
|
|
76
|
+
|
|
77
|
+
Depends on `vaid-pop`. For a local checkout:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
pip install -e python/vaid-pop
|
|
81
|
+
pip install -e python/vaid-langchain[test] # includes langchain-core + pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Other frameworks
|
|
85
|
+
|
|
86
|
+
CrewAI and Google ADK integrate through the **same `VaidAuth` seam** (subclass
|
|
87
|
+
`crewai.tools.BaseTool` / wrap an ADK `FunctionTool` over a `VaidAuth`-bearing
|
|
88
|
+
client). Only LangChain ships today; the shared auth is what makes the others
|
|
89
|
+
near-copies.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""5-minute quickstart: a LangChain tool whose calls are VAID-signed.
|
|
2
|
+
|
|
3
|
+
Run: python examples/quickstart.py
|
|
4
|
+
|
|
5
|
+
This uses an in-process mock backend (httpx.MockTransport) so it runs with no
|
|
6
|
+
server, no network, and no LLM/API key — it prints the four x-synthera-* PoP
|
|
7
|
+
headers the tool attaches to its outbound request, and verifies the signature.
|
|
8
|
+
|
|
9
|
+
IMPORTANT — provisioning is hand-done here, NOT managed by the SDK:
|
|
10
|
+
the VAID document and the Ed25519 private key below are hard-coded demo values.
|
|
11
|
+
In a real deployment you mint the VAID (see vaid-mint) and load the agent's
|
|
12
|
+
private key from your own secret store; the vaid SDK does not provision either.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
19
|
+
|
|
20
|
+
from vaid_pop import RequestSigner
|
|
21
|
+
from vaid_langchain import VaidAuth, make_vaid_tool
|
|
22
|
+
|
|
23
|
+
# ── Hand-provisioned demo identity (NOT SDK-managed) ──
|
|
24
|
+
DEMO_SEED = bytes.fromhex(
|
|
25
|
+
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
|
26
|
+
)
|
|
27
|
+
DEMO_VAID = {
|
|
28
|
+
"vaid_id": "11111111-1111-1111-1111-111111111111",
|
|
29
|
+
"tenant_id": "acme",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> None:
|
|
34
|
+
private_key = Ed25519PrivateKey.from_private_bytes(DEMO_SEED)
|
|
35
|
+
signer = RequestSigner(vaid=DEMO_VAID, private_key=private_key)
|
|
36
|
+
|
|
37
|
+
# A mock backend that echoes back the PoP headers it received.
|
|
38
|
+
def backend(request: httpx.Request) -> httpx.Response:
|
|
39
|
+
pop = {k: v for k, v in request.headers.items() if k.startswith("x-synthera-")}
|
|
40
|
+
print("── request arrived at the protected backend ──")
|
|
41
|
+
print(f" {request.method} {request.url.raw_path.decode('ascii')}")
|
|
42
|
+
for k, v in pop.items():
|
|
43
|
+
shown = v if len(v) < 60 else v[:57] + "..."
|
|
44
|
+
print(f" {k}: {shown}")
|
|
45
|
+
return httpx.Response(200, json={"minted": True})
|
|
46
|
+
|
|
47
|
+
client = httpx.Client(
|
|
48
|
+
base_url="https://api.example.com",
|
|
49
|
+
auth=VaidAuth(signer),
|
|
50
|
+
transport=httpx.MockTransport(backend),
|
|
51
|
+
)
|
|
52
|
+
tool = make_vaid_tool(signer, "https://api.example.com", client=client)
|
|
53
|
+
|
|
54
|
+
# An agent would call this tool; here we invoke it directly (no LLM needed).
|
|
55
|
+
result = tool.invoke(
|
|
56
|
+
{"path": "/vaid/mint?tenant=acme", "payload": {"seed": {"agentClass": "runner"}}}
|
|
57
|
+
)
|
|
58
|
+
print("\nbackend response:", result)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vaid-langchain"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain request-signing adapter for the VAID standard: sign every outbound agent HTTP request with a VAID proof-of-possession via a thin httpx.Auth seam."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "solara.associates", email = "info@solara.associates" }]
|
|
13
|
+
keywords = ["vaid", "langchain", "agents", "proof-of-possession", "httpx", "signing"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
]
|
|
22
|
+
# vaid-pop = the shared JCS→SHA-256→Ed25519 PoP signer (VaidAuth defers to it,
|
|
23
|
+
# never reimplements it). httpx = the client-auth seam (VaidAuth is an httpx.Auth).
|
|
24
|
+
# langchain-core is OPTIONAL: VaidAuth works with any httpx client standalone;
|
|
25
|
+
# only `make_vaid_tool` needs it, and it is imported lazily.
|
|
26
|
+
dependencies = [
|
|
27
|
+
"vaid-pop>=0.1.0",
|
|
28
|
+
"httpx>=0.27",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
# The packaged firewall: an external consumer who installed ONLY the wheel can run
|
|
33
|
+
# `vaid-langchain-conformance` to prove the adapter they got reproduces the frozen
|
|
34
|
+
# path-with-query vector byte-for-byte (via its own use of vaid-pop).
|
|
35
|
+
vaid-langchain-conformance = "vaid_langchain.conformance:main"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
langchain = [
|
|
39
|
+
"langchain-core>=0.3",
|
|
40
|
+
]
|
|
41
|
+
test = [
|
|
42
|
+
"pytest>=8.0",
|
|
43
|
+
"langchain-core>=0.3",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://github.com/solara-associates/vaid"
|
|
48
|
+
Repository = "https://github.com/solara-associates/vaid"
|
|
49
|
+
Source = "https://github.com/solara-associates/vaid/tree/main/python/vaid-langchain"
|
|
50
|
+
Issues = "https://github.com/solara-associates/vaid/issues"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["vaid_langchain"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.sdist]
|
|
56
|
+
include = ["vaid_langchain", "tests", "examples", "README.md"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The LangChain tool wrapper produces VAID-signed requests when invoked.
|
|
2
|
+
|
|
3
|
+
Proves the end-to-end path an agent takes: invoke the StructuredTool → it calls
|
|
4
|
+
the protected backend through the VaidAuth-bearing client → the request arriving
|
|
5
|
+
at the backend carries valid PoP headers. A MockTransport stands in for the
|
|
6
|
+
backend so no server/LLM is needed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey,
|
|
17
|
+
Ed25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from vaid_pop import (
|
|
21
|
+
HEADER_NONCE,
|
|
22
|
+
HEADER_SIGNATURE,
|
|
23
|
+
HEADER_TIMESTAMP,
|
|
24
|
+
HEADER_VAID,
|
|
25
|
+
RequestSigner,
|
|
26
|
+
build_request_auth_payload,
|
|
27
|
+
canonical_request_signing_bytes,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from vaid_langchain import VaidAuth, make_vaid_tool
|
|
31
|
+
from vaid_langchain._target import request_target
|
|
32
|
+
|
|
33
|
+
SEED_HEX = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
|
34
|
+
VAID_ID = "11111111-1111-1111-1111-111111111111"
|
|
35
|
+
TENANT_ID = "synthera-control-plane"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _key() -> Ed25519PrivateKey:
|
|
39
|
+
return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(SEED_HEX))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _signer() -> RequestSigner:
|
|
43
|
+
return RequestSigner(vaid={"vaid_id": VAID_ID, "tenant_id": TENANT_ID}, private_key=_key())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _verify(request: httpx.Request) -> bool:
|
|
47
|
+
payload = build_request_auth_payload(
|
|
48
|
+
vaid_id=VAID_ID,
|
|
49
|
+
method=request.method.upper(),
|
|
50
|
+
path=request_target(request),
|
|
51
|
+
body_sha256=hashlib.sha256(request.content or b"").hexdigest(),
|
|
52
|
+
tenant_id=TENANT_ID,
|
|
53
|
+
timestamp=request.headers[HEADER_TIMESTAMP],
|
|
54
|
+
client_nonce=request.headers[HEADER_NONCE],
|
|
55
|
+
)
|
|
56
|
+
digest = canonical_request_signing_bytes(payload)
|
|
57
|
+
sig = base64.b64decode(request.headers[HEADER_SIGNATURE])
|
|
58
|
+
pub = _key().public_key().public_bytes_raw()
|
|
59
|
+
try:
|
|
60
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest)
|
|
61
|
+
return True
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _tool_with_mock_backend(handler):
|
|
67
|
+
client = httpx.Client(
|
|
68
|
+
base_url="https://api.example.com",
|
|
69
|
+
auth=VaidAuth(_signer()),
|
|
70
|
+
transport=httpx.MockTransport(handler),
|
|
71
|
+
)
|
|
72
|
+
return make_vaid_tool(_signer(), "https://api.example.com", client=client)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_tool_invocation_sends_a_signed_request():
|
|
76
|
+
captured = {}
|
|
77
|
+
|
|
78
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
79
|
+
captured["req"] = request
|
|
80
|
+
return httpx.Response(200, text='{"ok": true}')
|
|
81
|
+
|
|
82
|
+
tool = _tool_with_mock_backend(handler)
|
|
83
|
+
out = tool.invoke({"path": "/vaid/mint", "payload": {"seed": {"agentClass": "x"}}})
|
|
84
|
+
|
|
85
|
+
assert out == '{"ok": true}'
|
|
86
|
+
req = captured["req"]
|
|
87
|
+
for h in (HEADER_VAID, HEADER_TIMESTAMP, HEADER_NONCE, HEADER_SIGNATURE):
|
|
88
|
+
assert h in req.headers
|
|
89
|
+
assert _verify(req), "the tool's outbound request must carry a valid PoP signature"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_tool_has_expected_name_and_is_a_langchain_tool():
|
|
93
|
+
tool = make_vaid_tool(_signer(), "https://api.example.com", name="mint_via_synthera")
|
|
94
|
+
from langchain_core.tools import BaseTool
|
|
95
|
+
|
|
96
|
+
assert isinstance(tool, BaseTool)
|
|
97
|
+
assert tool.name == "mint_via_synthera"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_tool_signs_query_string_in_path():
|
|
101
|
+
captured = {}
|
|
102
|
+
|
|
103
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
104
|
+
captured["req"] = request
|
|
105
|
+
return httpx.Response(200, text="ok")
|
|
106
|
+
|
|
107
|
+
tool = _tool_with_mock_backend(handler)
|
|
108
|
+
tool.invoke({"path": "/things?owner=acme&limit=5", "payload": {}})
|
|
109
|
+
|
|
110
|
+
req = captured["req"]
|
|
111
|
+
assert request_target(req) == "/things?owner=acme&limit=5"
|
|
112
|
+
assert _verify(req)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""PIN THE PATH/QUERY CONVENTION (Investigation 3's silent-failure landmine).
|
|
2
|
+
|
|
3
|
+
Proves that the request-target string the adapter signs (``request_target`` =
|
|
4
|
+
httpx ``raw_path`` = percent-encoded path+query):
|
|
5
|
+
|
|
6
|
+
1. is exactly what we intend, WITH and WITHOUT a query string present;
|
|
7
|
+
2. round-trips: sign → a verifier reconstructing the SAME target verifies OK;
|
|
8
|
+
3. is byte-identical to what the canonical RUST signer produces for the same
|
|
9
|
+
with-query target — asserted against the frozen, vendored vector
|
|
10
|
+
``pathquery_v1.json`` (regenerate via
|
|
11
|
+
``cargo run -p vaid-client --example emit_pop_pathquery``; a CI drift-check
|
|
12
|
+
enforces the Rust and Python copies are byte-identical);
|
|
13
|
+
4. fails closed if the WRONG attribute (``.path``, which drops the query) is
|
|
14
|
+
used on one side — the landmine bites loudly, not silently-at-runtime.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from importlib.resources import files
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
27
|
+
Ed25519PrivateKey,
|
|
28
|
+
Ed25519PublicKey,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from vaid_pop import RequestSigner, build_request_auth_payload, canonical_request_signing_bytes
|
|
32
|
+
|
|
33
|
+
from vaid_langchain._target import request_target
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _vector() -> dict:
|
|
37
|
+
"""The frozen path-with-query vector bundled with the package (byte-identical
|
|
38
|
+
to the Rust copy under crates/vaid-client/tests/vectors/)."""
|
|
39
|
+
data = files("vaid_langchain").joinpath("vectors/pathquery_v1.json").read_text()
|
|
40
|
+
return json.loads(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
V = _vector()
|
|
44
|
+
SEED_HEX = V["ed25519"]["private_key_seed_hex"]
|
|
45
|
+
VAID_ID = V["input"]["vaidId"]
|
|
46
|
+
TENANT_ID = V["input"]["tenantId"]
|
|
47
|
+
TIMESTAMP = V["input"]["timestamp"]
|
|
48
|
+
NONCE = V["input"]["clientNonce"]
|
|
49
|
+
QUERY_PATH = V["input"]["path"] # "/vaid/mint?tenant=acme&limit=10"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _key() -> Ed25519PrivateKey:
|
|
53
|
+
return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(SEED_HEX))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _signer() -> RequestSigner:
|
|
57
|
+
return RequestSigner(vaid={"vaid_id": VAID_ID, "tenant_id": TENANT_ID}, private_key=_key())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _verify(method: str, path: str, body: bytes, headers: dict) -> bool:
|
|
61
|
+
"""A stand-in verifier: reconstruct the payload from the SAME (method, path,
|
|
62
|
+
body) rule and check the signature against the agent public key."""
|
|
63
|
+
payload = build_request_auth_payload(
|
|
64
|
+
vaid_id=VAID_ID,
|
|
65
|
+
method=method.upper(),
|
|
66
|
+
path=path,
|
|
67
|
+
body_sha256=hashlib.sha256(body).hexdigest(),
|
|
68
|
+
tenant_id=TENANT_ID,
|
|
69
|
+
timestamp=headers["x-synthera-timestamp"],
|
|
70
|
+
client_nonce=headers["x-synthera-nonce"],
|
|
71
|
+
)
|
|
72
|
+
digest = canonical_request_signing_bytes(payload)
|
|
73
|
+
sig = base64.b64decode(headers["x-synthera-signature"])
|
|
74
|
+
pub = _key().public_key().public_bytes_raw()
|
|
75
|
+
try:
|
|
76
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest)
|
|
77
|
+
return True
|
|
78
|
+
except Exception:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_httpx_raw_path_yields_intended_target_without_query():
|
|
83
|
+
r = httpx.Request("POST", "https://api.example.com/vaid/mint", content=b"{}")
|
|
84
|
+
assert request_target(r) == "/vaid/mint"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_httpx_raw_path_yields_intended_target_with_query():
|
|
88
|
+
r = httpx.Request("POST", f"https://api.example.com{QUERY_PATH}", content=b"{}")
|
|
89
|
+
# The query IS included in the signed target — the whole point.
|
|
90
|
+
assert request_target(r) == QUERY_PATH
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_roundtrip_sign_then_verify_no_query():
|
|
94
|
+
r = httpx.Request("POST", "https://api.example.com/vaid/mint", content=b"")
|
|
95
|
+
path = request_target(r)
|
|
96
|
+
now = datetime.strptime(TIMESTAMP, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
97
|
+
headers = _signer().sign_headers("POST", path, b"", now=now, nonce=NONCE)
|
|
98
|
+
assert _verify("POST", path, b"", headers)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_roundtrip_sign_then_verify_with_query():
|
|
102
|
+
r = httpx.Request("POST", f"https://api.example.com{QUERY_PATH}", content=b"")
|
|
103
|
+
path = request_target(r)
|
|
104
|
+
now = datetime.strptime(TIMESTAMP, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
105
|
+
headers = _signer().sign_headers("POST", path, b"", now=now, nonce=NONCE)
|
|
106
|
+
assert _verify("POST", path, b"", headers)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_cross_language_signature_matches_frozen_vector_for_with_query_target():
|
|
110
|
+
"""Python signing of the httpx-extracted with-query target reproduces the
|
|
111
|
+
canonical RUST signature (the frozen vector) byte-for-byte — proving the exact
|
|
112
|
+
path string flows identically through both reference implementations."""
|
|
113
|
+
r = httpx.Request("POST", f"https://api.example.com{QUERY_PATH}", content=b"")
|
|
114
|
+
path = request_target(r)
|
|
115
|
+
assert path == V["input"]["path"]
|
|
116
|
+
|
|
117
|
+
payload = build_request_auth_payload(
|
|
118
|
+
vaid_id=VAID_ID,
|
|
119
|
+
method="POST",
|
|
120
|
+
path=path,
|
|
121
|
+
body_sha256=hashlib.sha256(b"").hexdigest(),
|
|
122
|
+
tenant_id=TENANT_ID,
|
|
123
|
+
timestamp=TIMESTAMP,
|
|
124
|
+
client_nonce=NONCE,
|
|
125
|
+
)
|
|
126
|
+
digest = canonical_request_signing_bytes(payload)
|
|
127
|
+
assert digest.hex() == V["digest_sha256_hex"], "digest diverged from the frozen vector — BLOCKER"
|
|
128
|
+
|
|
129
|
+
now = datetime.strptime(TIMESTAMP, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
130
|
+
headers = _signer().sign_headers("POST", path, b"", now=now, nonce=NONCE)
|
|
131
|
+
sig_hex = base64.b64decode(headers["x-synthera-signature"]).hex()
|
|
132
|
+
assert sig_hex == V["ed25519"]["signature_hex"], "signature diverged from the vector — BLOCKER"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_wrong_attribute_dot_path_drops_query_and_fails_closed():
|
|
136
|
+
"""THE LANDMINE: if the signer uses raw_path (with query) but a verifier uses
|
|
137
|
+
.path (query dropped), verification FAILS — the mismatch is caught, not
|
|
138
|
+
silently accepted. Proves picking the wrong attribute breaks loudly."""
|
|
139
|
+
r = httpx.Request("POST", f"https://api.example.com{QUERY_PATH}", content=b"")
|
|
140
|
+
signed_path = request_target(r) # raw_path: includes ?query
|
|
141
|
+
verifier_path = r.url.path # WRONG: query dropped
|
|
142
|
+
assert signed_path != verifier_path
|
|
143
|
+
|
|
144
|
+
now = datetime.strptime(TIMESTAMP, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
145
|
+
headers = _signer().sign_headers("POST", signed_path, b"", now=now, nonce=NONCE)
|
|
146
|
+
assert not _verify("POST", verifier_path, b"", headers)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""VaidAuth attaches valid PoP headers to every outbound request.
|
|
2
|
+
|
|
3
|
+
Uses httpx's MockTransport so no network/server is needed: the handler receives
|
|
4
|
+
the request AFTER auth_flow has run, so we can assert the four x-synthera-* headers
|
|
5
|
+
are present and that the signature verifies over the reconstructed payload — with
|
|
6
|
+
the raw_path (path+query) convention, and with a body-less (GET) request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import pytest
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
17
|
+
Ed25519PrivateKey,
|
|
18
|
+
Ed25519PublicKey,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from vaid_pop import (
|
|
22
|
+
HEADER_NONCE,
|
|
23
|
+
HEADER_SIGNATURE,
|
|
24
|
+
HEADER_TIMESTAMP,
|
|
25
|
+
HEADER_VAID,
|
|
26
|
+
RequestSigner,
|
|
27
|
+
build_request_auth_payload,
|
|
28
|
+
canonical_request_signing_bytes,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from vaid_langchain import VaidAuth
|
|
32
|
+
from vaid_langchain._target import request_target
|
|
33
|
+
|
|
34
|
+
SEED_HEX = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
|
35
|
+
VAID_ID = "11111111-1111-1111-1111-111111111111"
|
|
36
|
+
TENANT_ID = "synthera-control-plane"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _key() -> Ed25519PrivateKey:
|
|
40
|
+
return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(SEED_HEX))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _signer() -> RequestSigner:
|
|
44
|
+
return RequestSigner(vaid={"vaid_id": VAID_ID, "tenant_id": TENANT_ID}, private_key=_key())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _verify_headers(request: httpx.Request) -> bool:
|
|
48
|
+
"""Reconstruct the payload from the request using the SAME convention the auth
|
|
49
|
+
used (raw_path) and verify the PoP signature against the agent public key."""
|
|
50
|
+
target = request_target(request)
|
|
51
|
+
body = request.content or b""
|
|
52
|
+
payload = build_request_auth_payload(
|
|
53
|
+
vaid_id=VAID_ID,
|
|
54
|
+
method=request.method.upper(),
|
|
55
|
+
path=target,
|
|
56
|
+
body_sha256=hashlib.sha256(body).hexdigest(),
|
|
57
|
+
tenant_id=TENANT_ID,
|
|
58
|
+
timestamp=request.headers[HEADER_TIMESTAMP],
|
|
59
|
+
client_nonce=request.headers[HEADER_NONCE],
|
|
60
|
+
)
|
|
61
|
+
digest = canonical_request_signing_bytes(payload)
|
|
62
|
+
sig = base64.b64decode(request.headers[HEADER_SIGNATURE])
|
|
63
|
+
pub = _key().public_key().public_bytes_raw()
|
|
64
|
+
try:
|
|
65
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest)
|
|
66
|
+
return True
|
|
67
|
+
except Exception:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _client(handler) -> httpx.Client:
|
|
72
|
+
return httpx.Client(
|
|
73
|
+
base_url="https://api.example.com",
|
|
74
|
+
auth=VaidAuth(_signer()),
|
|
75
|
+
transport=httpx.MockTransport(handler),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_all_four_headers_attached_and_signature_valid_post_with_body():
|
|
80
|
+
captured = {}
|
|
81
|
+
|
|
82
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
83
|
+
captured["req"] = request
|
|
84
|
+
return httpx.Response(200, text="ok")
|
|
85
|
+
|
|
86
|
+
with _client(handler) as c:
|
|
87
|
+
c.post("/vaid/mint", json={"seed": {"agentClass": "x"}})
|
|
88
|
+
|
|
89
|
+
req = captured["req"]
|
|
90
|
+
for h in (HEADER_VAID, HEADER_TIMESTAMP, HEADER_NONCE, HEADER_SIGNATURE):
|
|
91
|
+
assert h in req.headers, f"missing {h}"
|
|
92
|
+
assert _verify_headers(req), "PoP signature must verify over the signed request"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_signature_covers_query_string():
|
|
96
|
+
captured = {}
|
|
97
|
+
|
|
98
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
99
|
+
captured["req"] = request
|
|
100
|
+
return httpx.Response(200, text="ok")
|
|
101
|
+
|
|
102
|
+
with _client(handler) as c:
|
|
103
|
+
c.post("/vaid/mint?tenant=acme&limit=10", json={})
|
|
104
|
+
|
|
105
|
+
req = captured["req"]
|
|
106
|
+
# The signed target includes the query, and it verifies.
|
|
107
|
+
assert request_target(req) == "/vaid/mint?tenant=acme&limit=10"
|
|
108
|
+
assert _verify_headers(req)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_tampering_with_query_after_signing_breaks_verification():
|
|
112
|
+
"""A verifier that sees a DIFFERENT query than what was signed must reject —
|
|
113
|
+
this is the property path-only signing would forfeit."""
|
|
114
|
+
captured = {}
|
|
115
|
+
|
|
116
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
117
|
+
captured["req"] = request
|
|
118
|
+
return httpx.Response(200, text="ok")
|
|
119
|
+
|
|
120
|
+
with _client(handler) as c:
|
|
121
|
+
c.post("/vaid/mint?tenant=acme", json={})
|
|
122
|
+
|
|
123
|
+
req = captured["req"]
|
|
124
|
+
# Simulate a verifier reconstructing with a tampered query.
|
|
125
|
+
payload = build_request_auth_payload(
|
|
126
|
+
vaid_id=VAID_ID,
|
|
127
|
+
method="POST",
|
|
128
|
+
path="/vaid/mint?tenant=attacker", # tampered
|
|
129
|
+
body_sha256=hashlib.sha256(req.content or b"").hexdigest(),
|
|
130
|
+
tenant_id=TENANT_ID,
|
|
131
|
+
timestamp=req.headers[HEADER_TIMESTAMP],
|
|
132
|
+
client_nonce=req.headers[HEADER_NONCE],
|
|
133
|
+
)
|
|
134
|
+
digest = canonical_request_signing_bytes(payload)
|
|
135
|
+
sig = base64.b64decode(req.headers[HEADER_SIGNATURE])
|
|
136
|
+
pub = _key().public_key().public_bytes_raw()
|
|
137
|
+
with pytest.raises(Exception):
|
|
138
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_body_less_get_signs_empty_body():
|
|
142
|
+
captured = {}
|
|
143
|
+
|
|
144
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
145
|
+
captured["req"] = request
|
|
146
|
+
return httpx.Response(200, text="ok")
|
|
147
|
+
|
|
148
|
+
with _client(handler) as c:
|
|
149
|
+
c.get("/status")
|
|
150
|
+
|
|
151
|
+
req = captured["req"]
|
|
152
|
+
assert (req.content or b"") == b""
|
|
153
|
+
assert _verify_headers(req), "a GET with no body must sign b'' and verify"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_each_request_gets_a_fresh_nonce():
|
|
157
|
+
nonces = []
|
|
158
|
+
|
|
159
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
160
|
+
nonces.append(request.headers[HEADER_NONCE])
|
|
161
|
+
return httpx.Response(200, text="ok")
|
|
162
|
+
|
|
163
|
+
with _client(handler) as c:
|
|
164
|
+
c.post("/a", json={})
|
|
165
|
+
c.post("/a", json={})
|
|
166
|
+
|
|
167
|
+
assert len(nonces) == 2 and nonces[0] != nonces[1], "nonce must be per-request fresh"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""vaid-langchain — LangChain request-signing adapter for the VAID standard.
|
|
2
|
+
|
|
3
|
+
Signs every outbound HTTP request an agent tool makes with a VAID proof-of-
|
|
4
|
+
possession, via a thin ``httpx.Auth`` seam:
|
|
5
|
+
|
|
6
|
+
from vaid_pop import RequestSigner
|
|
7
|
+
from vaid_langchain import make_vaid_tool
|
|
8
|
+
|
|
9
|
+
signer = RequestSigner(vaid=my_vaid_doc, private_key=my_key) # hand-provisioned
|
|
10
|
+
tool = make_vaid_tool(signer, base_url="https://api.example.com")
|
|
11
|
+
# give `tool` to a LangChain agent — its calls to the backend are VAID-signed.
|
|
12
|
+
|
|
13
|
+
The signing lives in :class:`VaidAuth` (an ``httpx.Auth``); the LangChain tool is
|
|
14
|
+
just a registration wrapper. :class:`VaidAuth` is usable standalone with any
|
|
15
|
+
``httpx`` client, LangChain not required. The signed request target is the
|
|
16
|
+
on-the-wire path + query (:func:`request_target`) — a security decision, see that
|
|
17
|
+
function's docs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from vaid_langchain._target import request_target
|
|
21
|
+
from vaid_langchain.auth import VaidAuth
|
|
22
|
+
from vaid_langchain.langchain_tool import make_vaid_tool
|
|
23
|
+
|
|
24
|
+
__all__ = ["VaidAuth", "make_vaid_tool", "request_target"]
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The pinned request-target convention for VAID request signing.
|
|
2
|
+
|
|
3
|
+
Investigation 3 flagged the one silent-failure landmine: the ``path`` a client
|
|
4
|
+
signs must be the exact string a verifier reconstructs, and whether the query
|
|
5
|
+
string is included is a convention that MUST be pinned — a mismatch rejects every
|
|
6
|
+
signature (or, worse, leaves query params unsigned and tamperable).
|
|
7
|
+
|
|
8
|
+
PINNED CONVENTION (permanent, a SECURITY decision — not a demo convenience): the
|
|
9
|
+
signed ``path`` is the **on-the-wire request target** — the percent-encoded path
|
|
10
|
+
plus ``?query`` when present — i.e. exactly what appears after the method on the
|
|
11
|
+
HTTP request line. For an ``httpx`` request this is ``request.url.raw_path``
|
|
12
|
+
(ASCII bytes), NOT ``request.url.path``.
|
|
13
|
+
|
|
14
|
+
Signing path-only would leave the query string **outside** the signature, so an
|
|
15
|
+
attacker could alter query parameters (``?tenant=…``, ``?limit=…``, ids, filters)
|
|
16
|
+
under a still-valid signature. Because the query frequently carries
|
|
17
|
+
authorization-relevant material, it MUST be part of the signed target. This is
|
|
18
|
+
why ``raw_path`` (path + query) is the permanent convention.
|
|
19
|
+
|
|
20
|
+
Why ``raw_path`` and not ``path``:
|
|
21
|
+
* ``.path`` DROPS the query string, leaving query params outside the signature
|
|
22
|
+
(an attacker can alter ``?tenant=…`` under a valid signature) — unacceptable.
|
|
23
|
+
* ``.path`` is percent-DECODED ("/a b/x"), so two distinct wire targets can map
|
|
24
|
+
to the same string — ambiguous.
|
|
25
|
+
* ``.raw_path`` is the percent-encoded path + query, deterministic and equal to
|
|
26
|
+
what a server reads from the request line — so a verifier reconstructs the
|
|
27
|
+
identical string.
|
|
28
|
+
|
|
29
|
+
The Rust canonical signer binds ``path`` verbatim (no normalization), so this
|
|
30
|
+
string is signed exactly as produced; the frozen operator vector's "/vaid/mint"
|
|
31
|
+
(no query) is the query-absent case of this same rule.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def request_target(request: Any) -> str:
|
|
40
|
+
"""The pinned signed-path string for an ``httpx.Request``: percent-encoded
|
|
41
|
+
path + query (the on-the-wire request target). ``raw_path`` is ASCII bytes;
|
|
42
|
+
decode to the ``str`` the signer/verifier bind."""
|
|
43
|
+
return request.url.raw_path.decode("ascii")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``VaidAuth`` — the shared httpx auth that signs any outbound request with a VAID.
|
|
2
|
+
|
|
3
|
+
Agent frameworks (LangChain, CrewAI, ADK) hook at the *tool-call* layer, which is
|
|
4
|
+
function-call-shaped (a name + kwargs). VAID proof-of-possession signs an *HTTP
|
|
5
|
+
request* — it binds ``(method, request-target, body_sha256)``. So the clean,
|
|
6
|
+
non-forking seam is NOT the framework's tool layer but the **HTTP client** the
|
|
7
|
+
tool uses: an ``httpx.Auth`` subclass runs on every outbound request and attaches
|
|
8
|
+
the four ``x-synthera-*`` PoP headers. This one class is the whole reusable
|
|
9
|
+
adapter; a framework "adapter" is then just that framework's idiomatic way of
|
|
10
|
+
registering a tool whose client carries this auth.
|
|
11
|
+
|
|
12
|
+
Canonicalization/signing is NOT reimplemented here — it defers to
|
|
13
|
+
``vaid_pop.RequestSigner``, so a conforming verifier derives identical bytes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Generator
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from vaid_pop import RequestSigner
|
|
22
|
+
|
|
23
|
+
from vaid_langchain._target import request_target
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VaidAuth(httpx.Auth):
|
|
27
|
+
"""Signs every outbound ``httpx`` request with a VAID proof-of-possession.
|
|
28
|
+
|
|
29
|
+
Attach to any client: ``httpx.Client(auth=VaidAuth(signer))``. On each request
|
|
30
|
+
it signs ``(method, raw_path, body)`` via ``vaid_pop.RequestSigner`` and
|
|
31
|
+
attaches the four ``x-synthera-*`` headers.
|
|
32
|
+
|
|
33
|
+
``requires_request_body = True`` is MANDATORY: without it, ``request.content``
|
|
34
|
+
is not populated inside ``auth_flow`` and the ``bodySha256`` would be computed
|
|
35
|
+
over an empty body while a non-empty body is sent — every signature would then
|
|
36
|
+
fail verification. Do not remove it.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# Tell httpx to read/buffer the request body before invoking auth_flow, so
|
|
40
|
+
# `request.content` is available here.
|
|
41
|
+
requires_request_body = True
|
|
42
|
+
|
|
43
|
+
def __init__(self, signer: RequestSigner) -> None:
|
|
44
|
+
self._signer = signer
|
|
45
|
+
|
|
46
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
|
|
47
|
+
# The signed target is the on-the-wire path + query (raw_path), the pinned
|
|
48
|
+
# security convention — see `_target.request_target`.
|
|
49
|
+
target = request_target(request)
|
|
50
|
+
# Body must be bytes; a body-less request (e.g. GET) signs b"" — never
|
|
51
|
+
# None (hashlib.sha256(None) raises) and never a str.
|
|
52
|
+
body = request.content or b""
|
|
53
|
+
headers = self._signer.sign_headers(request.method, target, body)
|
|
54
|
+
request.headers.update(headers)
|
|
55
|
+
yield request
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Packaged path-with-query conformance check — the firewall, shipped in the wheel.
|
|
2
|
+
|
|
3
|
+
Mirror of ``vaid_pop.conformance`` / ``vaid_mint.conformance``. A consumer who has
|
|
4
|
+
only ``pip install vaid-langchain`` can prove the adapter they installed reproduces
|
|
5
|
+
the frozen ``pathquery_v1.json`` vector byte-for-byte, using only the installed
|
|
6
|
+
package (and its ``vaid-pop`` dependency under the hood)::
|
|
7
|
+
|
|
8
|
+
python -m vaid_langchain.conformance # exit 0 = PASS, 1 = BLOCKER
|
|
9
|
+
vaid-langchain-conformance # same, via the console entry point
|
|
10
|
+
|
|
11
|
+
Programmatically::
|
|
12
|
+
|
|
13
|
+
from vaid_langchain.conformance import run
|
|
14
|
+
run() # raises ConformanceError on any divergence; returns the vector on PASS
|
|
15
|
+
|
|
16
|
+
The vector it checks is the one bundled with the package
|
|
17
|
+
(``vaid_langchain/vectors/pathquery_v1.json``). The Rust ``vaid-client``
|
|
18
|
+
``pathquery_conformance`` test asserts the identical vector; a repo-level
|
|
19
|
+
drift-check proves the two copies are byte-identical ⇒ Rust == Python == vector.
|
|
20
|
+
|
|
21
|
+
What is distinct here vs. the raw PoP firewall: this also confirms the adapter's
|
|
22
|
+
own pinned request-target convention — ``request_target`` (httpx ``raw_path`` =
|
|
23
|
+
percent-encoded path + query) reproduces the frozen ``path`` — before reproducing
|
|
24
|
+
the digest + signature over it. That ties the byte-identity proof to the exact
|
|
25
|
+
convention the adapter signs with.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from importlib.resources import files
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
38
|
+
Ed25519PrivateKey,
|
|
39
|
+
Ed25519PublicKey,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from vaid_pop import (
|
|
43
|
+
HEADER_SIGNATURE,
|
|
44
|
+
RequestSigner,
|
|
45
|
+
build_request_auth_payload,
|
|
46
|
+
canonical_request_signing_bytes,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from vaid_langchain._target import request_target
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConformanceError(AssertionError):
|
|
53
|
+
"""A cross-language byte-identity divergence — a hard BLOCKER, never ship-anyway."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_vector() -> dict:
|
|
57
|
+
"""The path-with-query conformance vector bundled with the installed package."""
|
|
58
|
+
data = files("vaid_langchain").joinpath("vectors/pathquery_v1.json").read_text()
|
|
59
|
+
return json.loads(data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_target_convention(v: dict) -> None:
|
|
63
|
+
"""The adapter's own request-target rule (``request_target`` = httpx raw_path)
|
|
64
|
+
reproduces the frozen `path`, and that path carries a query — the pinned,
|
|
65
|
+
security-relevant convention this vector exists to lock."""
|
|
66
|
+
path = v["input"]["path"]
|
|
67
|
+
if "?" not in path:
|
|
68
|
+
raise ConformanceError("pathquery vector's `path` must include a query string")
|
|
69
|
+
req = httpx.Request("POST", f"https://host{path}", content=b"")
|
|
70
|
+
got = request_target(req)
|
|
71
|
+
if got != path:
|
|
72
|
+
raise ConformanceError(
|
|
73
|
+
f"request_target diverged from the frozen path — BLOCKER\n"
|
|
74
|
+
f" got = {got}\n vector = {path}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_digest(v: dict) -> None:
|
|
79
|
+
"""Python JCS + SHA-256 over the camelCase RequestAuthPayload (path+query) ==
|
|
80
|
+
frozen digest."""
|
|
81
|
+
digest = canonical_request_signing_bytes(v["input"])
|
|
82
|
+
if digest.hex() != v["digest_sha256_hex"]:
|
|
83
|
+
raise ConformanceError(
|
|
84
|
+
f"canonical digest diverged from the frozen vector — BLOCKER\n"
|
|
85
|
+
f" got = {digest.hex()}\n vector = {v['digest_sha256_hex']}"
|
|
86
|
+
)
|
|
87
|
+
if len(digest) != 32:
|
|
88
|
+
raise ConformanceError(f"digest is {len(digest)} bytes, expected 32")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_signature(v: dict) -> None:
|
|
92
|
+
"""From the frozen seed, derive the same public key + deterministic signature."""
|
|
93
|
+
seed = bytes.fromhex(v["ed25519"]["private_key_seed_hex"])
|
|
94
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
95
|
+
|
|
96
|
+
pub = sk.public_key().public_bytes_raw()
|
|
97
|
+
if pub.hex() != v["ed25519"]["public_key_hex"]:
|
|
98
|
+
raise ConformanceError(
|
|
99
|
+
f"public key diverged — BLOCKER\n"
|
|
100
|
+
f" got = {pub.hex()}\n vector = {v['ed25519']['public_key_hex']}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
digest = canonical_request_signing_bytes(v["input"])
|
|
104
|
+
sig = sk.sign(digest)
|
|
105
|
+
if sig.hex() != v["ed25519"]["signature_hex"]:
|
|
106
|
+
raise ConformanceError(
|
|
107
|
+
f"signature diverged — BLOCKER\n"
|
|
108
|
+
f" got = {sig.hex()}\n vector = {v['ed25519']['signature_hex']}"
|
|
109
|
+
)
|
|
110
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest) # raises on failure
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_request_signer(v: dict) -> None:
|
|
114
|
+
"""End-to-end: the real signer path (vaid-pop RequestSigner) over the pinned
|
|
115
|
+
request target reproduces the frozen header signature byte-for-byte."""
|
|
116
|
+
inp = v["input"]
|
|
117
|
+
seed = bytes.fromhex(v["ed25519"]["private_key_seed_hex"])
|
|
118
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
119
|
+
signer = RequestSigner(
|
|
120
|
+
vaid={"vaid_id": inp["vaidId"], "tenant_id": inp["tenantId"]}, private_key=sk
|
|
121
|
+
)
|
|
122
|
+
# Re-derive the signed target through the adapter's own convention.
|
|
123
|
+
req = httpx.Request("POST", f"https://host{inp['path']}", content=b"")
|
|
124
|
+
target = request_target(req)
|
|
125
|
+
now = datetime.strptime(inp["timestamp"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
126
|
+
headers = signer.sign_headers(inp["method"], target, b"", now=now, nonce=inp["clientNonce"])
|
|
127
|
+
|
|
128
|
+
payload = build_request_auth_payload(
|
|
129
|
+
vaid_id=inp["vaidId"],
|
|
130
|
+
method=inp["method"].upper(),
|
|
131
|
+
path=target,
|
|
132
|
+
body_sha256=hashlib.sha256(b"").hexdigest(),
|
|
133
|
+
tenant_id=inp["tenantId"],
|
|
134
|
+
timestamp=inp["timestamp"],
|
|
135
|
+
client_nonce=inp["clientNonce"],
|
|
136
|
+
)
|
|
137
|
+
digest = canonical_request_signing_bytes(payload)
|
|
138
|
+
sig = base64.b64decode(headers[HEADER_SIGNATURE])
|
|
139
|
+
if sig.hex() != v["ed25519"]["signature_hex"]:
|
|
140
|
+
raise ConformanceError("header signature diverged — BLOCKER")
|
|
141
|
+
sk.public_key().verify(sig, digest) # raises on failure → header signature valid
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run() -> dict:
|
|
145
|
+
"""Run all firewall checks against the bundled vector. Raises ConformanceError
|
|
146
|
+
on any divergence; returns the vector on PASS."""
|
|
147
|
+
v = load_vector()
|
|
148
|
+
check_target_convention(v)
|
|
149
|
+
check_digest(v)
|
|
150
|
+
check_signature(v)
|
|
151
|
+
check_request_signer(v)
|
|
152
|
+
return v
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- pytest discovery (so `pytest --pyargs vaid_langchain` runs the firewall) ---
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_packaged_target_convention_matches_frozen_vector() -> None:
|
|
159
|
+
check_target_convention(load_vector())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_packaged_digest_matches_frozen_vector() -> None:
|
|
163
|
+
check_digest(load_vector())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_packaged_signature_matches_frozen_vector() -> None:
|
|
167
|
+
check_signature(load_vector())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_packaged_request_signer_matches_frozen_vector() -> None:
|
|
171
|
+
check_request_signer(load_vector())
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> int:
|
|
175
|
+
try:
|
|
176
|
+
v = run()
|
|
177
|
+
except ConformanceError as exc:
|
|
178
|
+
print(f"CROSS-LANGUAGE PATHQUERY FIREWALL: MISMATCH — BLOCKER\n{exc}")
|
|
179
|
+
return 1
|
|
180
|
+
print(
|
|
181
|
+
"CROSS-LANGUAGE PATHQUERY FIREWALL: PASS — installed adapter == frozen vector, "
|
|
182
|
+
"byte-for-byte\n"
|
|
183
|
+
f" path = {v['input']['path']}\n"
|
|
184
|
+
f" digest = {v['digest_sha256_hex']}\n"
|
|
185
|
+
f" signature = {v['ed25519']['signature_hex']}"
|
|
186
|
+
)
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""LangChain tool wrapper — a thin registration layer over :class:`VaidAuth`.
|
|
2
|
+
|
|
3
|
+
All the signing lives in :class:`~vaid_langchain.auth.VaidAuth`; this module is
|
|
4
|
+
just LangChain's idiomatic way of exposing a tool whose HTTP client carries that
|
|
5
|
+
auth. :func:`make_vaid_tool` returns a LangChain ``StructuredTool`` an agent can
|
|
6
|
+
call; every request the tool makes to the protected backend is VAID-signed.
|
|
7
|
+
|
|
8
|
+
``langchain_core`` is imported lazily so this package (and :class:`VaidAuth`)
|
|
9
|
+
imports without LangChain installed — you only need it for this wrapper.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from vaid_pop import RequestSigner
|
|
18
|
+
|
|
19
|
+
from vaid_langchain.auth import VaidAuth
|
|
20
|
+
|
|
21
|
+
_DEFAULT_DESCRIPTION = (
|
|
22
|
+
"Call a VAID-protected HTTP API. Pass the request `path` (e.g. '/vaid/mint', "
|
|
23
|
+
"including any ?query) and an optional JSON `payload`. The request is signed "
|
|
24
|
+
"with the agent's VAID proof-of-possession before it is sent."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_vaid_tool(
|
|
29
|
+
signer: RequestSigner,
|
|
30
|
+
base_url: str,
|
|
31
|
+
*,
|
|
32
|
+
name: str = "call_protected_api",
|
|
33
|
+
description: str = _DEFAULT_DESCRIPTION,
|
|
34
|
+
method: str = "POST",
|
|
35
|
+
client: httpx.Client | None = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Build a LangChain ``StructuredTool`` that calls a VAID-protected API.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
signer:
|
|
42
|
+
A ``vaid_pop.RequestSigner`` built from the agent's minted VAID document
|
|
43
|
+
and its Ed25519 private key. Provisioning that key/VAID is the caller's
|
|
44
|
+
job — see the README; it is NOT managed by this SDK.
|
|
45
|
+
base_url:
|
|
46
|
+
Base URL of the protected backend (e.g. ``https://api.example.com``).
|
|
47
|
+
name / description:
|
|
48
|
+
Surface the tool presents to the agent/LLM.
|
|
49
|
+
method:
|
|
50
|
+
HTTP method the tool issues (default ``POST``).
|
|
51
|
+
client:
|
|
52
|
+
Optional pre-built ``httpx.Client`` (used in tests to inject a mock
|
|
53
|
+
transport). When omitted, a client bound to ``base_url`` with
|
|
54
|
+
``VaidAuth(signer)`` is created.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
A ``langchain_core.tools.StructuredTool``.
|
|
59
|
+
"""
|
|
60
|
+
from langchain_core.tools import StructuredTool # lazy: LangChain optional
|
|
61
|
+
|
|
62
|
+
http = client or httpx.Client(base_url=base_url, auth=VaidAuth(signer))
|
|
63
|
+
|
|
64
|
+
def call_protected_api(path: str, payload: dict | None = None) -> str:
|
|
65
|
+
"""Send a signed request to the protected API and return the response body."""
|
|
66
|
+
resp = http.request(method, path, json=payload if payload is not None else {})
|
|
67
|
+
resp.raise_for_status()
|
|
68
|
+
return resp.text
|
|
69
|
+
|
|
70
|
+
return StructuredTool.from_function(
|
|
71
|
+
call_protected_api, name=name, description=description
|
|
72
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Path-with-query conformance vector (v1). Load-bearing. `input` is a real RequestAuthPayload (camelCase) whose `path` is the on-the-wire request target INCLUDING the query string — the pinned signing convention for framework adapters (httpx `raw_path`), NOT path-only. Signing path-only would leave the query outside the signature (tamperable), so this is a security decision. A conforming signer MUST produce `digest_sha256_hex` from `input` via JCS (RFC 8785) -> SHA-256 and reproduce `ed25519.signature_hex` byte-for-byte from the seed. Same JCS→SHA-256→Ed25519 primitive as operator_pop_v1.json.",
|
|
3
|
+
"digest_sha256_hex": "b5b3c06fe6fe9a3c7ea848abd2d8d974f71f37f2373fe3bb041821ded88de755",
|
|
4
|
+
"ed25519": {
|
|
5
|
+
"_comment": "Deterministic test key (same RFC 8032 seed as operator_pop_v1.json). Any conforming implementation derives the same public key and produces the same signature over digest_sha256_hex.",
|
|
6
|
+
"private_key_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
7
|
+
"public_key_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
|
8
|
+
"signature_hex": "eb67e1148bce0d3a3386882bf85139ef23f84a5ed876dadb992a666854afeee90d46281df65e1f96f543d1f0bd8f63ec70cbf0a141091f0c5a6bbe281d41880b"
|
|
9
|
+
},
|
|
10
|
+
"input": {
|
|
11
|
+
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
12
|
+
"clientNonce": "0123456789abcdef0123456789abcdef",
|
|
13
|
+
"method": "POST",
|
|
14
|
+
"path": "/vaid/mint?tenant=acme&limit=10",
|
|
15
|
+
"tenantId": "synthera-control-plane",
|
|
16
|
+
"timestamp": "2026-06-04T12:00:00Z",
|
|
17
|
+
"vaidId": "11111111-1111-1111-1111-111111111111"
|
|
18
|
+
},
|
|
19
|
+
"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"
|
|
20
|
+
}
|