veefore-py 1.1.0__py3-none-any.whl

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.
veefore/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """Veefore SDK for Python.
2
+
3
+ The SDK is a convenience layer, never a security boundary. Every protected
4
+ action is decided server-side by the gateway on every call. Nothing in this
5
+ package can grant an agent authority it does not already have, and nothing here
6
+ caches a permission.
7
+
8
+ from veefore import protect
9
+
10
+ agent = protect(my_agent)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .assertion import (
16
+ ASSERTION_TTL_SECONDS,
17
+ canonical_json,
18
+ create_assertion,
19
+ generate_nonce,
20
+ )
21
+ from .client import SDK_NAME, SDK_VERSION, VeeforeClient
22
+ from .errors import (
23
+ VeeforeApprovalDeniedError,
24
+ VeeforeApprovalRequiredError,
25
+ VeeforeAuthenticationError,
26
+ VeeforeBlockedError,
27
+ VeeforeConfigurationError,
28
+ VeeforeConnectionError,
29
+ VeeforeError,
30
+ VeeforeRateLimitError,
31
+ )
32
+ from .models import (
33
+ ActionDecision,
34
+ ActionRequest,
35
+ ApprovalStatus,
36
+ HandshakeResult,
37
+ StageResult,
38
+ )
39
+ from .guard import guard, guard_client, reset_guard_client
40
+ from .protect import ProtectedAgent, config_from_env, create_client, protect
41
+
42
+ __version__ = SDK_VERSION
43
+
44
+ __all__ = [
45
+ "protect",
46
+ "guard",
47
+ "guard_client",
48
+ "reset_guard_client",
49
+ "create_client",
50
+ "config_from_env",
51
+ "ProtectedAgent",
52
+ "VeeforeClient",
53
+ "ActionRequest",
54
+ "ActionDecision",
55
+ "ApprovalStatus",
56
+ "HandshakeResult",
57
+ "StageResult",
58
+ "VeeforeError",
59
+ "VeeforeAuthenticationError",
60
+ "VeeforeBlockedError",
61
+ "VeeforeApprovalRequiredError",
62
+ "VeeforeApprovalDeniedError",
63
+ "VeeforeConnectionError",
64
+ "VeeforeConfigurationError",
65
+ "VeeforeRateLimitError",
66
+ "create_assertion",
67
+ "generate_nonce",
68
+ "canonical_json",
69
+ "ASSERTION_TTL_SECONDS",
70
+ "SDK_NAME",
71
+ "SDK_VERSION",
72
+ "__version__",
73
+ ]
veefore/assertion.py ADDED
@@ -0,0 +1,119 @@
1
+ """Agent identity assertions.
2
+
3
+ A compact JWS (EdDSA / Ed25519) proving possession of the agent's private
4
+ identity key, bound to the agent id, the gateway audience, a single-use nonce
5
+ and a short expiry.
6
+
7
+ The private key never leaves this module: it is not logged, not placed in a
8
+ request body, and not written to disk.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import json
15
+ import secrets
16
+ from datetime import datetime, timedelta, timezone
17
+ from typing import Any, Optional, Tuple
18
+
19
+ from cryptography.hazmat.primitives import serialization
20
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
21
+
22
+ from .errors import VeeforeConfigurationError
23
+
24
+ __all__ = ["ASSERTION_TTL_SECONDS", "canonical_json", "create_assertion", "generate_nonce"]
25
+
26
+ ASSERTION_TTL_SECONDS = 120
27
+
28
+
29
+ def _b64url(data: bytes) -> str:
30
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
31
+
32
+
33
+ def canonical_json(value: Any) -> str:
34
+ """RFC 8785-style canonical JSON: sorted keys, no insignificant whitespace.
35
+
36
+ Must match the server's canonicalisation byte for byte or signatures fail.
37
+ """
38
+ if value is None:
39
+ return "null"
40
+ if isinstance(value, bool):
41
+ return "true" if value else "false"
42
+ if isinstance(value, (int, float)):
43
+ return json.dumps(value)
44
+ if isinstance(value, str):
45
+ return json.dumps(value, ensure_ascii=False)
46
+ if isinstance(value, (list, tuple)):
47
+ return "[" + ",".join(canonical_json(item) for item in value) + "]"
48
+ if isinstance(value, dict):
49
+ items = sorted((k, v) for k, v in value.items() if v is not None)
50
+ return (
51
+ "{"
52
+ + ",".join(
53
+ f"{json.dumps(k, ensure_ascii=False)}:{canonical_json(v)}" for k, v in items
54
+ )
55
+ + "}"
56
+ )
57
+ return "null"
58
+
59
+
60
+ def generate_nonce() -> str:
61
+ return secrets.token_urlsafe(18)
62
+
63
+
64
+ def _load_private_key(base64_pkcs8: str) -> Ed25519PrivateKey:
65
+ try:
66
+ der = base64.b64decode(base64_pkcs8, validate=False)
67
+ key = serialization.load_der_private_key(der, password=None)
68
+ except Exception as error: # noqa: BLE001 - surfaced as a config error
69
+ raise VeeforeConfigurationError(
70
+ "The agent private key could not be parsed. "
71
+ "Expected base64-encoded PKCS#8 Ed25519 material."
72
+ ) from error
73
+ if not isinstance(key, Ed25519PrivateKey):
74
+ raise VeeforeConfigurationError(
75
+ "The agent private key is not an Ed25519 key. Veefore agent identities use Ed25519."
76
+ )
77
+ return key
78
+
79
+
80
+ def create_assertion(
81
+ *,
82
+ agent_id: str,
83
+ audience: str,
84
+ private_key: str,
85
+ key_id: str,
86
+ purpose: str = "agent_authentication",
87
+ nonce: Optional[str] = None,
88
+ ttl_seconds: int = ASSERTION_TTL_SECONDS,
89
+ now: Optional[datetime] = None,
90
+ ) -> Tuple[str, str]:
91
+ """Returns ``(assertion, nonce)``."""
92
+ issued_at = now or datetime.now(timezone.utc)
93
+ actual_nonce = nonce or generate_nonce()
94
+
95
+ header = {"typ": "veefore-assertion+jwt", "alg": "EdDSA", "kid": key_id}
96
+ payload = {
97
+ "sub": agent_id,
98
+ "aud": audience,
99
+ "purpose": purpose,
100
+ "nonce": actual_nonce,
101
+ "iat": _iso(issued_at),
102
+ "exp": _iso(issued_at + timedelta(seconds=ttl_seconds)),
103
+ }
104
+
105
+ signing_input = (
106
+ _b64url(json.dumps(header, separators=(",", ":")).encode("utf-8"))
107
+ + "."
108
+ + _b64url(canonical_json(payload).encode("utf-8"))
109
+ )
110
+ key = _load_private_key(private_key)
111
+ signature = key.sign(signing_input.encode("ascii"))
112
+ return f"{signing_input}.{_b64url(signature)}", actual_nonce
113
+
114
+
115
+ def _iso(value: datetime) -> str:
116
+ """ISO-8601 with a trailing ``Z``, matching the server's format."""
117
+ return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace(
118
+ "+00:00", "Z"
119
+ )