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.
@@ -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
+ }