asap-protocol 0.3.0__py3-none-any.whl → 0.5.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.
- asap/__init__.py +1 -1
- asap/errors.py +167 -0
- asap/examples/README.md +3 -0
- asap/examples/run_demo.py +9 -2
- asap/models/__init__.py +4 -0
- asap/models/constants.py +73 -0
- asap/models/entities.py +38 -2
- asap/models/envelope.py +7 -1
- asap/transport/__init__.py +3 -0
- asap/transport/circuit_breaker.py +193 -0
- asap/transport/client.py +588 -53
- asap/transport/middleware.py +6 -5
- asap/transport/server.py +80 -3
- asap/transport/validators.py +324 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +139 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-0.5.0.dist-info}/METADATA +22 -5
- {asap_protocol-0.3.0.dist-info → asap_protocol-0.5.0.dist-info}/RECORD +21 -17
- {asap_protocol-0.3.0.dist-info → asap_protocol-0.5.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-0.5.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-0.5.0.dist-info}/licenses/LICENSE +0 -0
asap/transport/middleware.py
CHANGED
|
@@ -32,7 +32,6 @@ Example:
|
|
|
32
32
|
>>> middleware = AuthenticationMiddleware(manifest, validator)
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
|
-
import hashlib
|
|
36
35
|
import uuid
|
|
37
36
|
from typing import Any, Awaitable, Callable, Protocol
|
|
38
37
|
from collections.abc import Sequence
|
|
@@ -47,6 +46,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
47
46
|
|
|
48
47
|
from asap.models.entities import Manifest
|
|
49
48
|
from asap.observability import get_logger
|
|
49
|
+
from asap.utils.sanitization import sanitize_token
|
|
50
50
|
|
|
51
51
|
logger = get_logger(__name__)
|
|
52
52
|
|
|
@@ -259,7 +259,8 @@ HTTP_TOO_MANY_REQUESTS = 429
|
|
|
259
259
|
|
|
260
260
|
# Error messages
|
|
261
261
|
ERROR_AUTH_REQUIRED = "Authentication required"
|
|
262
|
-
|
|
262
|
+
# nosec B105: This is an error message constant, not a hardcoded password
|
|
263
|
+
ERROR_INVALID_TOKEN = "Invalid authentication token" # nosec B105
|
|
263
264
|
ERROR_SENDER_MISMATCH = "Sender does not match authenticated identity"
|
|
264
265
|
ERROR_RATE_LIMIT_EXCEEDED = "Rate limit exceeded"
|
|
265
266
|
|
|
@@ -498,12 +499,12 @@ class AuthenticationMiddleware:
|
|
|
498
499
|
agent_id = self.validator(token)
|
|
499
500
|
|
|
500
501
|
if agent_id is None:
|
|
501
|
-
# Log token
|
|
502
|
-
|
|
502
|
+
# Log sanitized token to avoid exposing full token data
|
|
503
|
+
token_prefix = sanitize_token(token)
|
|
503
504
|
logger.warning(
|
|
504
505
|
"asap.auth.invalid_token",
|
|
505
506
|
manifest_id=self.manifest.id,
|
|
506
|
-
|
|
507
|
+
token_prefix=token_prefix,
|
|
507
508
|
)
|
|
508
509
|
raise HTTPException(
|
|
509
510
|
status_code=HTTP_UNAUTHORIZED,
|
asap/transport/server.py
CHANGED
|
@@ -51,11 +51,12 @@ from fastapi.responses import JSONResponse, PlainTextResponse
|
|
|
51
51
|
from pydantic import ValidationError
|
|
52
52
|
from slowapi.errors import RateLimitExceeded
|
|
53
53
|
|
|
54
|
-
from asap.errors import ThreadPoolExhaustedError
|
|
54
|
+
from asap.errors import InvalidNonceError, InvalidTimestampError, ThreadPoolExhaustedError
|
|
55
55
|
from asap.models.constants import MAX_REQUEST_SIZE
|
|
56
56
|
from asap.models.entities import Capability, Endpoint, Manifest, Skill
|
|
57
57
|
from asap.models.envelope import Envelope
|
|
58
58
|
from asap.observability import get_logger, get_metrics
|
|
59
|
+
from asap.utils.sanitization import sanitize_nonce
|
|
59
60
|
from asap.transport.middleware import (
|
|
60
61
|
AuthenticationMiddleware,
|
|
61
62
|
BearerTokenValidator,
|
|
@@ -83,6 +84,12 @@ from asap.transport.jsonrpc import (
|
|
|
83
84
|
JsonRpcRequest,
|
|
84
85
|
JsonRpcResponse,
|
|
85
86
|
)
|
|
87
|
+
from asap.transport.validators import (
|
|
88
|
+
InMemoryNonceStore,
|
|
89
|
+
NonceStore,
|
|
90
|
+
validate_envelope_nonce,
|
|
91
|
+
validate_envelope_timestamp,
|
|
92
|
+
)
|
|
86
93
|
|
|
87
94
|
# Module logger
|
|
88
95
|
logger = get_logger(__name__)
|
|
@@ -142,6 +149,7 @@ class ASAPRequestHandler:
|
|
|
142
149
|
manifest: Manifest,
|
|
143
150
|
auth_middleware: AuthenticationMiddleware | None = None,
|
|
144
151
|
max_request_size: int = MAX_REQUEST_SIZE,
|
|
152
|
+
nonce_store: NonceStore | None = None,
|
|
145
153
|
) -> None:
|
|
146
154
|
"""Initialize the request handler.
|
|
147
155
|
|
|
@@ -150,11 +158,13 @@ class ASAPRequestHandler:
|
|
|
150
158
|
manifest: Agent manifest describing capabilities
|
|
151
159
|
auth_middleware: Optional authentication middleware for request validation
|
|
152
160
|
max_request_size: Maximum allowed request size in bytes
|
|
161
|
+
nonce_store: Optional nonce store for replay attack prevention
|
|
153
162
|
"""
|
|
154
163
|
self.registry = registry
|
|
155
164
|
self.manifest = manifest
|
|
156
165
|
self.auth_middleware = auth_middleware
|
|
157
166
|
self.max_request_size = max_request_size
|
|
167
|
+
self.nonce_store = nonce_store
|
|
158
168
|
|
|
159
169
|
def _normalize_payload_type_for_metrics(self, payload_type: str) -> str:
|
|
160
170
|
"""Normalize payload type for metrics to prevent cardinality explosion.
|
|
@@ -808,7 +818,9 @@ class ASAPRequestHandler:
|
|
|
808
818
|
if parse_error is not None:
|
|
809
819
|
return parse_error
|
|
810
820
|
# Type narrowing: rpc_request is not None here
|
|
811
|
-
assert
|
|
821
|
+
# Use explicit check instead of assert to avoid removal in optimized builds
|
|
822
|
+
if rpc_request is None:
|
|
823
|
+
raise RuntimeError("Internal error: rpc_request is None after validation")
|
|
812
824
|
|
|
813
825
|
# Create request context
|
|
814
826
|
ctx = RequestContext(
|
|
@@ -844,6 +856,58 @@ class ASAPRequestHandler:
|
|
|
844
856
|
if sender_error is not None:
|
|
845
857
|
return sender_error
|
|
846
858
|
|
|
859
|
+
# Validate envelope timestamp to prevent replay attacks
|
|
860
|
+
try:
|
|
861
|
+
validate_envelope_timestamp(envelope)
|
|
862
|
+
except InvalidTimestampError as e:
|
|
863
|
+
logger.warning(
|
|
864
|
+
"asap.request.invalid_timestamp",
|
|
865
|
+
envelope_id=envelope.id,
|
|
866
|
+
error=e.message,
|
|
867
|
+
details=e.details,
|
|
868
|
+
)
|
|
869
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
870
|
+
self.record_error_metrics(
|
|
871
|
+
ctx.metrics, payload_type, "invalid_timestamp", duration_seconds
|
|
872
|
+
)
|
|
873
|
+
return self.build_error_response(
|
|
874
|
+
INVALID_PARAMS,
|
|
875
|
+
data={
|
|
876
|
+
"error": "Invalid envelope timestamp",
|
|
877
|
+
"code": e.code,
|
|
878
|
+
"message": e.message,
|
|
879
|
+
"details": e.details,
|
|
880
|
+
},
|
|
881
|
+
request_id=ctx.request_id,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
# Validate envelope nonce if nonce store is available
|
|
885
|
+
try:
|
|
886
|
+
validate_envelope_nonce(envelope, self.nonce_store)
|
|
887
|
+
except InvalidNonceError as e:
|
|
888
|
+
# Sanitize nonce in logs to prevent full value exposure
|
|
889
|
+
nonce_sanitized = sanitize_nonce(e.nonce)
|
|
890
|
+
logger.warning(
|
|
891
|
+
"asap.request.invalid_nonce",
|
|
892
|
+
envelope_id=envelope.id,
|
|
893
|
+
nonce=nonce_sanitized,
|
|
894
|
+
error=e.message,
|
|
895
|
+
)
|
|
896
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
897
|
+
self.record_error_metrics(
|
|
898
|
+
ctx.metrics, payload_type, "invalid_nonce", duration_seconds
|
|
899
|
+
)
|
|
900
|
+
return self.build_error_response(
|
|
901
|
+
INVALID_PARAMS,
|
|
902
|
+
data={
|
|
903
|
+
"error": "Invalid envelope nonce",
|
|
904
|
+
"code": e.code,
|
|
905
|
+
"message": e.message,
|
|
906
|
+
"details": e.details,
|
|
907
|
+
},
|
|
908
|
+
request_id=ctx.request_id,
|
|
909
|
+
)
|
|
910
|
+
|
|
847
911
|
# Log request received
|
|
848
912
|
logger.info(
|
|
849
913
|
"asap.request.received",
|
|
@@ -893,6 +957,7 @@ def create_app(
|
|
|
893
957
|
rate_limit: str | None = None,
|
|
894
958
|
max_request_size: int | None = None,
|
|
895
959
|
max_threads: int | None = None,
|
|
960
|
+
require_nonce: bool = False,
|
|
896
961
|
) -> FastAPI:
|
|
897
962
|
"""Create and configure a FastAPI application for ASAP protocol.
|
|
898
963
|
|
|
@@ -920,6 +985,9 @@ def create_app(
|
|
|
920
985
|
max_threads: Optional maximum number of threads for sync handlers.
|
|
921
986
|
Defaults to ASAP_MAX_THREADS environment variable or min(32, cpu_count + 4).
|
|
922
987
|
Set to None to use unbounded executor (not recommended for production).
|
|
988
|
+
require_nonce: If True, enables nonce validation for replay attack prevention.
|
|
989
|
+
When enabled, creates an InMemoryNonceStore and validates nonces in envelopes.
|
|
990
|
+
Defaults to False (nonce validation is optional).
|
|
923
991
|
|
|
924
992
|
Returns:
|
|
925
993
|
Configured FastAPI application ready to run
|
|
@@ -1003,8 +1071,17 @@ def create_app(
|
|
|
1003
1071
|
if max_request_size is None:
|
|
1004
1072
|
max_request_size = int(os.getenv("ASAP_MAX_REQUEST_SIZE", str(MAX_REQUEST_SIZE)))
|
|
1005
1073
|
|
|
1074
|
+
# Create nonce store if required
|
|
1075
|
+
nonce_store: NonceStore | None = None
|
|
1076
|
+
if require_nonce:
|
|
1077
|
+
nonce_store = InMemoryNonceStore()
|
|
1078
|
+
logger.info(
|
|
1079
|
+
"asap.server.nonce_validation_enabled",
|
|
1080
|
+
manifest_id=manifest.id,
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1006
1083
|
# Create request handler
|
|
1007
|
-
handler = ASAPRequestHandler(registry, manifest, auth_middleware, max_request_size)
|
|
1084
|
+
handler = ASAPRequestHandler(registry, manifest, auth_middleware, max_request_size, nonce_store)
|
|
1008
1085
|
|
|
1009
1086
|
app = FastAPI(
|
|
1010
1087
|
title="ASAP Protocol Server",
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Validation utilities for ASAP protocol envelopes.
|
|
2
|
+
|
|
3
|
+
This module provides validation functions for envelope security checks,
|
|
4
|
+
including timestamp validation for replay attack prevention and optional
|
|
5
|
+
nonce validation for duplicate detection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from asap.errors import InvalidNonceError, InvalidTimestampError
|
|
14
|
+
from asap.models.constants import (
|
|
15
|
+
MAX_ENVELOPE_AGE_SECONDS,
|
|
16
|
+
MAX_FUTURE_TOLERANCE_SECONDS,
|
|
17
|
+
NONCE_TTL_SECONDS,
|
|
18
|
+
)
|
|
19
|
+
from asap.models.envelope import Envelope
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_envelope_timestamp(envelope: Envelope) -> None:
|
|
23
|
+
"""Validate envelope timestamp to prevent replay attacks.
|
|
24
|
+
|
|
25
|
+
Checks that the envelope timestamp is:
|
|
26
|
+
- Not older than MAX_ENVELOPE_AGE_SECONDS (default: 5 minutes)
|
|
27
|
+
- Not more than MAX_FUTURE_TOLERANCE_SECONDS in the future (default: 30 seconds)
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
envelope: The envelope to validate
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
InvalidTimestampError: If the timestamp is too old or too far in the future
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> from asap.models.envelope import Envelope
|
|
37
|
+
>>> from asap.transport.validators import validate_envelope_timestamp
|
|
38
|
+
>>>
|
|
39
|
+
>>> # Recent envelope passes
|
|
40
|
+
>>> recent = Envelope(
|
|
41
|
+
... asap_version="0.1",
|
|
42
|
+
... sender="urn:asap:agent:test",
|
|
43
|
+
... recipient="urn:asap:agent:test",
|
|
44
|
+
... payload_type="TaskRequest",
|
|
45
|
+
... payload={}
|
|
46
|
+
... )
|
|
47
|
+
>>> validate_envelope_timestamp(recent) # No exception
|
|
48
|
+
>>>
|
|
49
|
+
>>> # Old envelope raises error
|
|
50
|
+
>>> from datetime import datetime, timezone, timedelta
|
|
51
|
+
>>> old = Envelope(
|
|
52
|
+
... asap_version="0.1",
|
|
53
|
+
... sender="urn:asap:agent:test",
|
|
54
|
+
... recipient="urn:asap:agent:test",
|
|
55
|
+
... payload_type="TaskRequest",
|
|
56
|
+
... payload={},
|
|
57
|
+
... timestamp=datetime.now(timezone.utc) - timedelta(minutes=10)
|
|
58
|
+
... )
|
|
59
|
+
>>> validate_envelope_timestamp(old) # Raises InvalidTimestampError
|
|
60
|
+
"""
|
|
61
|
+
if envelope.timestamp is None:
|
|
62
|
+
raise InvalidTimestampError(
|
|
63
|
+
timestamp="",
|
|
64
|
+
message="Envelope timestamp is required for validation",
|
|
65
|
+
details={"envelope_id": envelope.id},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
now = datetime.now(timezone.utc)
|
|
69
|
+
timestamp = envelope.timestamp
|
|
70
|
+
|
|
71
|
+
# Ensure timestamp is timezone-aware (should be UTC)
|
|
72
|
+
if timestamp.tzinfo is None:
|
|
73
|
+
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
|
74
|
+
else:
|
|
75
|
+
# Convert to UTC if needed
|
|
76
|
+
timestamp = timestamp.astimezone(timezone.utc)
|
|
77
|
+
|
|
78
|
+
# Calculate age in seconds
|
|
79
|
+
age_seconds = (now - timestamp).total_seconds()
|
|
80
|
+
|
|
81
|
+
# Check if envelope is too old
|
|
82
|
+
if age_seconds > MAX_ENVELOPE_AGE_SECONDS:
|
|
83
|
+
raise InvalidTimestampError(
|
|
84
|
+
timestamp=timestamp.isoformat(),
|
|
85
|
+
message=(
|
|
86
|
+
f"Envelope timestamp is too old: {age_seconds:.1f} seconds "
|
|
87
|
+
f"(max: {MAX_ENVELOPE_AGE_SECONDS} seconds)"
|
|
88
|
+
),
|
|
89
|
+
age_seconds=age_seconds,
|
|
90
|
+
details={
|
|
91
|
+
"envelope_id": envelope.id,
|
|
92
|
+
"max_age_seconds": MAX_ENVELOPE_AGE_SECONDS,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Check if envelope timestamp is too far in the future
|
|
97
|
+
future_offset_seconds = (timestamp - now).total_seconds()
|
|
98
|
+
if future_offset_seconds > MAX_FUTURE_TOLERANCE_SECONDS:
|
|
99
|
+
raise InvalidTimestampError(
|
|
100
|
+
timestamp=timestamp.isoformat(),
|
|
101
|
+
message=(
|
|
102
|
+
f"Envelope timestamp is too far in the future: "
|
|
103
|
+
f"{future_offset_seconds:.1f} seconds "
|
|
104
|
+
f"(max tolerance: {MAX_FUTURE_TOLERANCE_SECONDS} seconds)"
|
|
105
|
+
),
|
|
106
|
+
future_offset_seconds=future_offset_seconds,
|
|
107
|
+
details={
|
|
108
|
+
"envelope_id": envelope.id,
|
|
109
|
+
"max_future_tolerance_seconds": MAX_FUTURE_TOLERANCE_SECONDS,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@runtime_checkable
|
|
115
|
+
class NonceStore(Protocol):
|
|
116
|
+
"""Protocol for nonce storage and validation.
|
|
117
|
+
|
|
118
|
+
Implementations must provide thread-safe storage and TTL-based expiration
|
|
119
|
+
for nonce values to prevent replay attacks.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def is_used(self, nonce: str) -> bool:
|
|
123
|
+
"""Check if a nonce has been used.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
nonce: The nonce value to check
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if the nonce has been used, False otherwise
|
|
130
|
+
"""
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
def mark_used(self, nonce: str, ttl_seconds: int) -> None:
|
|
134
|
+
"""Mark a nonce as used with a TTL.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
nonce: The nonce value to mark as used
|
|
138
|
+
ttl_seconds: Time-to-live in seconds for the nonce
|
|
139
|
+
"""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
def check_and_mark(self, nonce: str, ttl_seconds: int) -> bool:
|
|
143
|
+
"""Atomically check if nonce is used and mark it if not.
|
|
144
|
+
|
|
145
|
+
This method performs both check and mark operations atomically to
|
|
146
|
+
prevent race conditions between concurrent requests.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
nonce: The nonce value to check and mark
|
|
150
|
+
ttl_seconds: Time-to-live in seconds for the nonce
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
True if nonce was already used, False if it was newly marked
|
|
154
|
+
"""
|
|
155
|
+
...
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class InMemoryNonceStore:
|
|
159
|
+
"""In-memory nonce store with TTL-based expiration.
|
|
160
|
+
|
|
161
|
+
This implementation uses a dictionary to store nonces with their
|
|
162
|
+
expiration times. Expired nonces are lazily cleaned up on access.
|
|
163
|
+
|
|
164
|
+
Attributes:
|
|
165
|
+
_store: Dictionary mapping nonce to expiration timestamp
|
|
166
|
+
_lock: Thread lock for thread-safe operations
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self) -> None:
|
|
170
|
+
"""Initialize in-memory nonce store."""
|
|
171
|
+
self._store: dict[str, float] = {}
|
|
172
|
+
self._lock = threading.RLock()
|
|
173
|
+
|
|
174
|
+
def _cleanup_expired(self) -> None:
|
|
175
|
+
"""Remove expired nonces from the store.
|
|
176
|
+
|
|
177
|
+
This is called lazily during access operations to keep the store
|
|
178
|
+
from growing unbounded.
|
|
179
|
+
"""
|
|
180
|
+
now = time.time()
|
|
181
|
+
expired = [nonce for nonce, expiry in self._store.items() if expiry < now]
|
|
182
|
+
for nonce in expired:
|
|
183
|
+
self._store.pop(nonce, None)
|
|
184
|
+
|
|
185
|
+
def is_used(self, nonce: str) -> bool:
|
|
186
|
+
"""Check if a nonce has been used.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
nonce: The nonce value to check
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if the nonce has been used and not expired, False otherwise
|
|
193
|
+
"""
|
|
194
|
+
with self._lock:
|
|
195
|
+
self._cleanup_expired()
|
|
196
|
+
if nonce not in self._store:
|
|
197
|
+
return False
|
|
198
|
+
expiry = self._store[nonce]
|
|
199
|
+
return expiry >= time.time()
|
|
200
|
+
|
|
201
|
+
def mark_used(self, nonce: str, ttl_seconds: int) -> None:
|
|
202
|
+
"""Mark a nonce as used with a TTL.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
nonce: The nonce value to mark as used
|
|
206
|
+
ttl_seconds: Time-to-live in seconds for the nonce
|
|
207
|
+
"""
|
|
208
|
+
with self._lock:
|
|
209
|
+
self._cleanup_expired()
|
|
210
|
+
expiry = time.time() + ttl_seconds
|
|
211
|
+
self._store[nonce] = expiry
|
|
212
|
+
|
|
213
|
+
def check_and_mark(self, nonce: str, ttl_seconds: int) -> bool:
|
|
214
|
+
"""Atomically check if nonce is used and mark it if not.
|
|
215
|
+
|
|
216
|
+
This method performs both check and mark operations atomically to
|
|
217
|
+
prevent race conditions between concurrent requests with the same nonce.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
nonce: The nonce value to check and mark
|
|
221
|
+
ttl_seconds: Time-to-live in seconds for the nonce
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if nonce was already used, False if it was newly marked
|
|
225
|
+
"""
|
|
226
|
+
with self._lock:
|
|
227
|
+
self._cleanup_expired()
|
|
228
|
+
if nonce in self._store and self._store[nonce] >= time.time():
|
|
229
|
+
return True # Already used
|
|
230
|
+
self._store[nonce] = time.time() + ttl_seconds
|
|
231
|
+
return False # Newly marked
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def validate_envelope_nonce(envelope: Envelope, nonce_store: NonceStore | None) -> None:
|
|
235
|
+
"""Validate envelope nonce to prevent duplicate message replay.
|
|
236
|
+
|
|
237
|
+
If the envelope has a nonce in its extensions, checks that it hasn't
|
|
238
|
+
been used before. If no nonce is present, validation passes (nonce
|
|
239
|
+
is optional). If a nonce_store is not provided, validation is skipped.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
envelope: The envelope to validate
|
|
243
|
+
nonce_store: Optional nonce store for duplicate detection
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
InvalidNonceError: If the nonce has been used before
|
|
247
|
+
|
|
248
|
+
Example:
|
|
249
|
+
>>> from asap.models.envelope import Envelope
|
|
250
|
+
>>> from asap.transport.validators import (
|
|
251
|
+
... validate_envelope_nonce,
|
|
252
|
+
... InMemoryNonceStore
|
|
253
|
+
... )
|
|
254
|
+
>>>
|
|
255
|
+
>>> store = InMemoryNonceStore()
|
|
256
|
+
>>>
|
|
257
|
+
>>> # Envelope without nonce passes
|
|
258
|
+
>>> envelope1 = Envelope(
|
|
259
|
+
... asap_version="0.1",
|
|
260
|
+
... sender="urn:asap:agent:test",
|
|
261
|
+
... recipient="urn:asap:agent:test",
|
|
262
|
+
... payload_type="TaskRequest",
|
|
263
|
+
... payload={}
|
|
264
|
+
... )
|
|
265
|
+
>>> validate_envelope_nonce(envelope1, store) # No exception
|
|
266
|
+
>>>
|
|
267
|
+
>>> # First use of nonce passes
|
|
268
|
+
>>> envelope2 = Envelope(
|
|
269
|
+
... asap_version="0.1",
|
|
270
|
+
... sender="urn:asap:agent:test",
|
|
271
|
+
... recipient="urn:asap:agent:test",
|
|
272
|
+
... payload_type="TaskRequest",
|
|
273
|
+
... payload={},
|
|
274
|
+
... extensions={"nonce": "unique-nonce-123"}
|
|
275
|
+
... )
|
|
276
|
+
>>> validate_envelope_nonce(envelope2, store) # No exception
|
|
277
|
+
>>>
|
|
278
|
+
>>> # Duplicate nonce raises error
|
|
279
|
+
>>> envelope3 = Envelope(
|
|
280
|
+
... asap_version="0.1",
|
|
281
|
+
... sender="urn:asap:agent:test",
|
|
282
|
+
... recipient="urn:asap:agent:test",
|
|
283
|
+
... payload_type="TaskRequest",
|
|
284
|
+
... payload={},
|
|
285
|
+
... extensions={"nonce": "unique-nonce-123"}
|
|
286
|
+
... )
|
|
287
|
+
>>> validate_envelope_nonce(envelope3, store) # Raises InvalidNonceError
|
|
288
|
+
"""
|
|
289
|
+
# Skip validation if no nonce store provided
|
|
290
|
+
if nonce_store is None:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
# Skip validation if no nonce in extensions
|
|
294
|
+
if not envelope.extensions or "nonce" not in envelope.extensions:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
nonce = envelope.extensions["nonce"]
|
|
298
|
+
|
|
299
|
+
# Validate nonce is a non-empty string
|
|
300
|
+
if not isinstance(nonce, str) or not nonce:
|
|
301
|
+
raise InvalidNonceError(
|
|
302
|
+
nonce=str(nonce),
|
|
303
|
+
message=(
|
|
304
|
+
f"Nonce must be a non-empty string, got "
|
|
305
|
+
f"{type(nonce).__name__ if not isinstance(nonce, str) else 'empty string'}"
|
|
306
|
+
),
|
|
307
|
+
details={"envelope_id": envelope.id},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Atomically check and mark nonce as used to prevent race conditions
|
|
311
|
+
if nonce_store.check_and_mark(nonce, ttl_seconds=NONCE_TTL_SECONDS):
|
|
312
|
+
raise InvalidNonceError(
|
|
313
|
+
nonce=nonce,
|
|
314
|
+
message=f"Duplicate nonce detected: {nonce}",
|
|
315
|
+
details={"envelope_id": envelope.id},
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
__all__ = [
|
|
320
|
+
"NonceStore",
|
|
321
|
+
"InMemoryNonceStore",
|
|
322
|
+
"validate_envelope_timestamp",
|
|
323
|
+
"validate_envelope_nonce",
|
|
324
|
+
]
|
asap/utils/__init__.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Log sanitization utilities for sensitive data protection.
|
|
2
|
+
|
|
3
|
+
This module provides functions to sanitize sensitive data before logging
|
|
4
|
+
to prevent credential leaks, token exposure, and other security issues.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from asap.utils.sanitization import sanitize_token, sanitize_nonce, sanitize_url
|
|
8
|
+
>>>
|
|
9
|
+
>>> # Sanitize a token (shows only first 8 characters)
|
|
10
|
+
>>> token = "sk_live_1234567890abcdef"
|
|
11
|
+
>>> sanitize_token(token) # Returns: "sk_live_..."
|
|
12
|
+
>>>
|
|
13
|
+
>>> # Sanitize a nonce (shows first 8 characters + "...")
|
|
14
|
+
>>> nonce = "a1b2c3d4e5f6g7h8i9j0"
|
|
15
|
+
>>> sanitize_nonce(nonce) # Returns: "a1b2c3d4..."
|
|
16
|
+
>>>
|
|
17
|
+
>>> # Sanitize a URL with credentials
|
|
18
|
+
>>> url = "https://user:password@example.com/api"
|
|
19
|
+
>>> sanitize_url(url) # Returns: "https://user:***@example.com/api"
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from urllib.parse import urlparse, urlunparse
|
|
24
|
+
|
|
25
|
+
# Sanitization configuration
|
|
26
|
+
SANITIZE_PREFIX_LENGTH = 8
|
|
27
|
+
"""Number of characters to show when truncating sensitive values.
|
|
28
|
+
|
|
29
|
+
This constant defines how many characters of a sensitive value (token, nonce)
|
|
30
|
+
are preserved when sanitizing for logs. The value balances security (preventing
|
|
31
|
+
full exposure) with debuggability (allowing identification of value types).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sanitize_token(token: str) -> str:
|
|
36
|
+
"""Sanitize a token for safe logging.
|
|
37
|
+
|
|
38
|
+
Returns only the first SANITIZE_PREFIX_LENGTH characters followed by "..."
|
|
39
|
+
to prevent full token exposure in logs while still allowing identification
|
|
40
|
+
of token type (e.g., "sk_live_", "pk_test_").
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
token: The token to sanitize
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Sanitized token string showing only prefix (e.g., "sk_live_...")
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
>>> sanitize_token("sk_live_1234567890abcdef")
|
|
50
|
+
'sk_live_...'
|
|
51
|
+
>>> sanitize_token("short")
|
|
52
|
+
'short'
|
|
53
|
+
"""
|
|
54
|
+
if not token:
|
|
55
|
+
return ""
|
|
56
|
+
if len(token) <= SANITIZE_PREFIX_LENGTH:
|
|
57
|
+
return token
|
|
58
|
+
return f"{token[:SANITIZE_PREFIX_LENGTH]}..."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sanitize_nonce(nonce: str) -> str:
|
|
62
|
+
"""Sanitize a nonce for safe logging.
|
|
63
|
+
|
|
64
|
+
Returns the first SANITIZE_PREFIX_LENGTH characters followed by "..."
|
|
65
|
+
to prevent full nonce exposure in logs while still allowing identification
|
|
66
|
+
for debugging purposes.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
nonce: The nonce to sanitize
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Sanitized nonce string showing first characters + "..." (e.g., "a1b2c3d4...")
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> sanitize_nonce("a1b2c3d4e5f6g7h8i9j0")
|
|
76
|
+
'a1b2c3d4...'
|
|
77
|
+
>>> sanitize_nonce("short")
|
|
78
|
+
'short'
|
|
79
|
+
"""
|
|
80
|
+
if not nonce:
|
|
81
|
+
return ""
|
|
82
|
+
if len(nonce) <= SANITIZE_PREFIX_LENGTH:
|
|
83
|
+
return nonce
|
|
84
|
+
return f"{nonce[:SANITIZE_PREFIX_LENGTH]}..."
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def sanitize_url(url: str) -> str:
|
|
88
|
+
"""Sanitize a URL by masking credentials in the userinfo component.
|
|
89
|
+
|
|
90
|
+
Masks passwords in URLs of the form:
|
|
91
|
+
- `https://user:password@example.com` → `https://user:***@example.com`
|
|
92
|
+
- `https://user@example.com` → `https://user@example.com` (no change)
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
url: The URL to sanitize
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Sanitized URL with masked credentials
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
>>> sanitize_url("https://user:secret@example.com/api")
|
|
102
|
+
'https://user:***@example.com/api'
|
|
103
|
+
>>> sanitize_url("https://example.com/api")
|
|
104
|
+
'https://example.com/api'
|
|
105
|
+
"""
|
|
106
|
+
if not url:
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
parsed = urlparse(url)
|
|
111
|
+
if (parsed.username or parsed.password) and parsed.password:
|
|
112
|
+
# Mask password if present
|
|
113
|
+
# Reconstruct netloc with masked password
|
|
114
|
+
netloc = f"{parsed.username}:***@{parsed.hostname}"
|
|
115
|
+
if parsed.port:
|
|
116
|
+
netloc = f"{netloc}:{parsed.port}"
|
|
117
|
+
# Reconstruct URL with sanitized netloc
|
|
118
|
+
return urlunparse(
|
|
119
|
+
(
|
|
120
|
+
parsed.scheme,
|
|
121
|
+
netloc,
|
|
122
|
+
parsed.path,
|
|
123
|
+
parsed.params,
|
|
124
|
+
parsed.query,
|
|
125
|
+
parsed.fragment,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return url
|
|
129
|
+
except Exception:
|
|
130
|
+
# If URL parsing fails, return a safe fallback
|
|
131
|
+
# Remove any obvious credential patterns
|
|
132
|
+
return re.sub(r"://[^:]+:[^@]+@", r"://***:***@", url)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = [
|
|
136
|
+
"sanitize_token",
|
|
137
|
+
"sanitize_nonce",
|
|
138
|
+
"sanitize_url",
|
|
139
|
+
]
|