aauth 0.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.
- aauth/__init__.py +137 -0
- aauth/agent/__init__.py +2 -0
- aauth/agent/challenge_handler.py +61 -0
- aauth/agent/signer.py +78 -0
- aauth/errors.py +51 -0
- aauth/headers/__init__.py +2 -0
- aauth/headers/agent_auth.py +146 -0
- aauth/headers/signature.py +53 -0
- aauth/headers/signature_input.py +65 -0
- aauth/headers/signature_key.py +99 -0
- aauth/http/__init__.py +2 -0
- aauth/http/request.py +140 -0
- aauth/http/response.py +106 -0
- aauth/keys/__init__.py +2 -0
- aauth/keys/jwk.py +123 -0
- aauth/keys/jwks.py +191 -0
- aauth/keys/keypair.py +16 -0
- aauth/metadata/__init__.py +2 -0
- aauth/metadata/agent.py +20 -0
- aauth/metadata/auth_server.py +114 -0
- aauth/metadata/resource.py +42 -0
- aauth/resource/__init__.py +2 -0
- aauth/resource/challenge_builder.py +87 -0
- aauth/resource/token_issuer.py +74 -0
- aauth/resource/verifier.py +170 -0
- aauth/signing/__init__.py +2 -0
- aauth/signing/algorithms.py +35 -0
- aauth/signing/signature_base.py +193 -0
- aauth/signing/signer.py +138 -0
- aauth/signing/verifier.py +323 -0
- aauth/tokens/__init__.py +2 -0
- aauth/tokens/agent_token.py +202 -0
- aauth/tokens/auth_token.py +227 -0
- aauth/tokens/resource_token.py +68 -0
- aauth-0.1.0.dist-info/METADATA +584 -0
- aauth-0.1.0.dist-info/RECORD +39 -0
- aauth-0.1.0.dist-info/WHEEL +5 -0
- aauth-0.1.0.dist-info/licenses/LICENSE +21 -0
- aauth-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Signature base construction for HTTP Message Signing (RFC 9421)."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Tuple, Dict, Optional
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_signature_base(
|
|
10
|
+
method: str,
|
|
11
|
+
authority: str,
|
|
12
|
+
path: str,
|
|
13
|
+
query: Optional[str],
|
|
14
|
+
headers: Dict[str, str],
|
|
15
|
+
body: Optional[bytes],
|
|
16
|
+
signature_key_header: str,
|
|
17
|
+
covered_components: Optional[List[str]] = None,
|
|
18
|
+
signature_params: Optional[str] = None
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Build signature base string per RFC 9421 Section 2.5.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
method: HTTP method
|
|
24
|
+
authority: Canonical authority (host:port)
|
|
25
|
+
path: Request path
|
|
26
|
+
query: Query string (without leading "?")
|
|
27
|
+
headers: Request headers
|
|
28
|
+
body: Request body bytes (None if no body)
|
|
29
|
+
signature_key_header: Signature-Key header value
|
|
30
|
+
covered_components: Optional list of components to cover (auto-detected if None)
|
|
31
|
+
signature_params: Signature-Input header value (required for @signature-params line)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Signature base string
|
|
35
|
+
"""
|
|
36
|
+
# Auto-detect covered components if not provided
|
|
37
|
+
if covered_components is None:
|
|
38
|
+
covered_components = _determine_covered_components(query, body, additional_components=None)
|
|
39
|
+
|
|
40
|
+
# Build component list
|
|
41
|
+
components: List[Tuple[str, str]] = []
|
|
42
|
+
|
|
43
|
+
for component_name in covered_components:
|
|
44
|
+
if component_name == "@method":
|
|
45
|
+
components.append(("@method", method))
|
|
46
|
+
elif component_name == "@authority":
|
|
47
|
+
components.append(("@authority", authority))
|
|
48
|
+
elif component_name == "@path":
|
|
49
|
+
components.append(("@path", path))
|
|
50
|
+
elif component_name == "@query":
|
|
51
|
+
if query:
|
|
52
|
+
# RFC 9421 Section 2.2.7: @query value MUST include leading ?
|
|
53
|
+
components.append(("@query", f"?{query}"))
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError("@query component specified but no query string present")
|
|
56
|
+
elif component_name == "content-type":
|
|
57
|
+
if body:
|
|
58
|
+
content_type = _get_header(headers, "content-type")
|
|
59
|
+
if content_type:
|
|
60
|
+
components.append(("content-type", content_type))
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError("content-type component required but header missing")
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError("content-type component specified but no body present")
|
|
65
|
+
elif component_name == "content-digest":
|
|
66
|
+
if body:
|
|
67
|
+
content_digest = _get_header(headers, "content-digest")
|
|
68
|
+
if content_digest:
|
|
69
|
+
components.append(("content-digest", content_digest))
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError("content-digest component required but header missing")
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError("content-digest component specified but no body present")
|
|
74
|
+
elif component_name == "signature-key":
|
|
75
|
+
components.append(("signature-key", signature_key_header))
|
|
76
|
+
elif component_name == "nonce":
|
|
77
|
+
nonce_value = _get_header(headers, "nonce")
|
|
78
|
+
if nonce_value:
|
|
79
|
+
components.append(("nonce", nonce_value))
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError("nonce component required but Nonce header missing")
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(f"Unknown component: {component_name}")
|
|
84
|
+
|
|
85
|
+
# Build signature base (RFC 9421 Section 2.5)
|
|
86
|
+
signature_base_parts = []
|
|
87
|
+
for component_name, component_value in components:
|
|
88
|
+
if component_name.startswith("@"):
|
|
89
|
+
signature_base_parts.append(f'"{component_name}": {component_value}')
|
|
90
|
+
else:
|
|
91
|
+
header_name = component_name.lower()
|
|
92
|
+
signature_base_parts.append(f'"{header_name}": {component_value}')
|
|
93
|
+
|
|
94
|
+
# Add @signature-params as the FINAL line (RFC 9421 Section 2.5 requirement)
|
|
95
|
+
# The @signature-params line contains the Signature-Input header value (without the label prefix)
|
|
96
|
+
# RFC 9421 Section 2.5: @signature-params MUST be present
|
|
97
|
+
if not signature_params:
|
|
98
|
+
raise ValueError("signature_params is required for valid signature base")
|
|
99
|
+
signature_base_parts.append(f'"@signature-params": {signature_params}')
|
|
100
|
+
|
|
101
|
+
signature_base = "\n".join(signature_base_parts)
|
|
102
|
+
|
|
103
|
+
return signature_base
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _determine_covered_components(
|
|
107
|
+
query: Optional[str],
|
|
108
|
+
body: Optional[bytes],
|
|
109
|
+
additional_components: Optional[List[str]] = None
|
|
110
|
+
) -> List[str]:
|
|
111
|
+
"""Determine covered components based on request structure.
|
|
112
|
+
|
|
113
|
+
Per AAuth spec Section 10.3:
|
|
114
|
+
- Always: @method, @authority, @path, signature-key
|
|
115
|
+
- If query present: @query
|
|
116
|
+
- Body components (content-type, content-digest) are opt-in via additional_components
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
query: Query string (None if no query)
|
|
120
|
+
body: Request body (None if no body)
|
|
121
|
+
additional_components: Optional list of additional components to include (e.g., ["content-type", "content-digest", "nonce"])
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of component names
|
|
125
|
+
"""
|
|
126
|
+
components = ["@method", "@authority", "@path"]
|
|
127
|
+
|
|
128
|
+
if query:
|
|
129
|
+
components.append("@query")
|
|
130
|
+
|
|
131
|
+
# Body components are opt-in via additional_components
|
|
132
|
+
# Only add if explicitly requested
|
|
133
|
+
if additional_components:
|
|
134
|
+
components.extend(additional_components)
|
|
135
|
+
|
|
136
|
+
# signature-key MUST always be included
|
|
137
|
+
components.append("signature-key")
|
|
138
|
+
|
|
139
|
+
return components
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _get_header(headers: Dict[str, str], name: str) -> Optional[str]:
|
|
143
|
+
"""Get header value (case-insensitive).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
headers: Headers dictionary
|
|
147
|
+
name: Header name (case-insensitive)
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Header value or None
|
|
151
|
+
"""
|
|
152
|
+
name_lower = name.lower()
|
|
153
|
+
for key, value in headers.items():
|
|
154
|
+
if key.lower() == name_lower:
|
|
155
|
+
return value
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_signature_params(
|
|
160
|
+
covered_components: List[str],
|
|
161
|
+
created: int,
|
|
162
|
+
keyid: Optional[str] = None
|
|
163
|
+
) -> str:
|
|
164
|
+
"""Build the Signature-Input value (the part after the label).
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
covered_components: List of component names
|
|
168
|
+
created: Creation timestamp (Unix time)
|
|
169
|
+
keyid: Optional key identifier
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Signature params string: ("@method" "@authority" ...);created=1234567890
|
|
173
|
+
"""
|
|
174
|
+
components_str = " ".join(f'"{c}"' for c in covered_components)
|
|
175
|
+
params = f"({components_str});created={created}"
|
|
176
|
+
if keyid:
|
|
177
|
+
params += f';keyid="{keyid}"'
|
|
178
|
+
return params
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def calculate_content_digest(body: bytes) -> str:
|
|
182
|
+
"""Calculate Content-Digest header value per RFC 9530.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
body: Request body bytes
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Content-Digest header value (e.g., "sha-256=:...:")
|
|
189
|
+
"""
|
|
190
|
+
digest = hashlib.sha256(body).digest()
|
|
191
|
+
digest_b64 = base64.b64encode(digest).decode('ascii')
|
|
192
|
+
return f"sha-256=:{digest_b64}:"
|
|
193
|
+
|
aauth/signing/signer.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""HTTP request signing for AAuth."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, Optional, List
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
import time
|
|
6
|
+
from ..headers.signature_key import build_signature_key_header
|
|
7
|
+
from ..headers.signature_input import build_signature_input_header
|
|
8
|
+
from ..headers.signature import build_signature_header
|
|
9
|
+
from ..signing.signature_base import build_signature_base, calculate_content_digest, build_signature_params
|
|
10
|
+
from ..errors import SignatureError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sign_request(
|
|
14
|
+
method: str,
|
|
15
|
+
target_uri: str,
|
|
16
|
+
headers: Dict[str, str],
|
|
17
|
+
body: Optional[bytes],
|
|
18
|
+
private_key,
|
|
19
|
+
sig_scheme: str = "hwk",
|
|
20
|
+
additional_signature_components: Optional[List[str]] = None,
|
|
21
|
+
**kwargs
|
|
22
|
+
) -> Dict[str, str]:
|
|
23
|
+
"""Sign an HTTP request using HTTP Message Signatures (RFC 9421).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
method: HTTP method (GET, POST, etc.)
|
|
27
|
+
target_uri: Target URI
|
|
28
|
+
headers: Request headers dictionary (will be modified)
|
|
29
|
+
body: Request body bytes (None if no body)
|
|
30
|
+
private_key: Ed25519 private key
|
|
31
|
+
sig_scheme: Signature scheme - "hwk", "jwks", or "jwt"
|
|
32
|
+
**kwargs: Additional parameters for signature schemes:
|
|
33
|
+
- For "jwks": id (required), kid (required), well-known (optional)
|
|
34
|
+
- For "jwt": jwt (required)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dictionary with Signature-Input, Signature, and Signature-Key headers
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
SignatureError: If signing fails
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
# Parse URI for derived components
|
|
44
|
+
parsed_uri = urlparse(target_uri)
|
|
45
|
+
authority = parsed_uri.netloc
|
|
46
|
+
path = parsed_uri.path or "/"
|
|
47
|
+
query_string = parsed_uri.query if parsed_uri.query else None
|
|
48
|
+
|
|
49
|
+
# Build Signature-Key header first (needed for signature-key component)
|
|
50
|
+
# Use label "sig1" to match Signature-Input and Signature headers
|
|
51
|
+
signature_key_header = build_signature_key_header(
|
|
52
|
+
sig_scheme=sig_scheme,
|
|
53
|
+
private_key=private_key,
|
|
54
|
+
label="sig1",
|
|
55
|
+
**kwargs
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Add Signature-Key to headers (needed for signature-key component)
|
|
59
|
+
headers["Signature-Key"] = signature_key_header
|
|
60
|
+
|
|
61
|
+
# Determine body components to include (opt-in only, per SPEC_NOTES.md)
|
|
62
|
+
# Body components are NOT automatic - only included if explicitly requested
|
|
63
|
+
# via additional_signature_components parameter (typically from server metadata)
|
|
64
|
+
body_components = []
|
|
65
|
+
if body and additional_signature_components:
|
|
66
|
+
# Only add if explicitly requested via additional_signature_components
|
|
67
|
+
for comp in additional_signature_components:
|
|
68
|
+
if comp in ("content-type", "content-digest"):
|
|
69
|
+
body_components.append(comp)
|
|
70
|
+
|
|
71
|
+
# Add Content-Digest header if needed and not already present (RFC 9530)
|
|
72
|
+
# Only calculate Content-Digest if it's in body_components but not in headers
|
|
73
|
+
if "content-digest" in body_components and "Content-Digest" not in headers:
|
|
74
|
+
content_digest = calculate_content_digest(body)
|
|
75
|
+
headers["Content-Digest"] = content_digest
|
|
76
|
+
|
|
77
|
+
# Add content-type header if needed and not already present
|
|
78
|
+
if "content-type" in body_components and "Content-Type" not in headers:
|
|
79
|
+
headers["Content-Type"] = "application/octet-stream"
|
|
80
|
+
|
|
81
|
+
# Check for Nonce header (per SPEC.md Section 10.5)
|
|
82
|
+
# Nonce MUST be included if present, regardless of body or additional_signature_components
|
|
83
|
+
if "Nonce" in headers:
|
|
84
|
+
body_components.append("nonce")
|
|
85
|
+
|
|
86
|
+
# Determine covered components
|
|
87
|
+
from ..signing.signature_base import _determine_covered_components
|
|
88
|
+
covered_components = _determine_covered_components(
|
|
89
|
+
query_string,
|
|
90
|
+
body,
|
|
91
|
+
additional_components=body_components
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Build signature params using new function
|
|
95
|
+
created = int(time.time())
|
|
96
|
+
signature_params = build_signature_params(
|
|
97
|
+
covered_components=covered_components,
|
|
98
|
+
created=created
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Build Signature-Input header (needed for @signature-params in signature base)
|
|
102
|
+
signature_input_header = f"sig1={signature_params}"
|
|
103
|
+
|
|
104
|
+
# Build signature base (now includes @signature-params per RFC 9421)
|
|
105
|
+
signature_base = build_signature_base(
|
|
106
|
+
method=method,
|
|
107
|
+
authority=authority,
|
|
108
|
+
path=path,
|
|
109
|
+
query=query_string,
|
|
110
|
+
headers=headers,
|
|
111
|
+
body=body,
|
|
112
|
+
signature_key_header=signature_key_header,
|
|
113
|
+
covered_components=covered_components,
|
|
114
|
+
signature_params=signature_params
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
import logging
|
|
118
|
+
logger = logging.getLogger("aauth.signing")
|
|
119
|
+
logger.debug(f"🔐 AAUTH LIBRARY SIGNATURE BASE:")
|
|
120
|
+
logger.debug(f"🔐 Signature base length: {len(signature_base)} bytes")
|
|
121
|
+
logger.debug(f"🔐 Signature base hex (first 200): {signature_base.encode('utf-8').hex()[:200]}...")
|
|
122
|
+
for i, line in enumerate(signature_base.split('\n')):
|
|
123
|
+
logger.debug(f"🔐 Line {i}: {repr(line)}")
|
|
124
|
+
|
|
125
|
+
# Sign the signature base
|
|
126
|
+
signature_bytes = private_key.sign(signature_base.encode('utf-8'))
|
|
127
|
+
|
|
128
|
+
# Build Signature header
|
|
129
|
+
signature_header = build_signature_header(signature_bytes, label="sig1")
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"Signature-Input": signature_input_header,
|
|
133
|
+
"Signature": signature_header,
|
|
134
|
+
"Signature-Key": signature_key_header
|
|
135
|
+
}
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise SignatureError(f"Failed to sign request: {e}", details={"scheme": sig_scheme}) from e
|
|
138
|
+
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""HTTP signature verification for AAuth."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from typing import Dict, Any, Optional, Callable
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
import jwt
|
|
8
|
+
from ..headers.signature_key import parse_signature_key
|
|
9
|
+
from ..headers.signature_input import parse_signature_input
|
|
10
|
+
from ..headers.signature import parse_signature
|
|
11
|
+
from ..signing.signature_base import build_signature_base
|
|
12
|
+
from ..keys.jwk import jwk_to_public_key
|
|
13
|
+
from ..tokens.agent_token import verify_agent_token
|
|
14
|
+
from ..errors import SignatureError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify_signature(
|
|
18
|
+
method: str,
|
|
19
|
+
target_uri: str,
|
|
20
|
+
headers: Dict[str, str],
|
|
21
|
+
body: Optional[bytes],
|
|
22
|
+
signature_input_header: str,
|
|
23
|
+
signature_header: str,
|
|
24
|
+
signature_key_header: str,
|
|
25
|
+
public_key=None,
|
|
26
|
+
jwks_fetcher: Optional[Callable] = None
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""Verify HTTP signature using HTTP Message Signatures (RFC 9421).
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
method: HTTP method
|
|
32
|
+
target_uri: Target URI
|
|
33
|
+
headers: Request headers
|
|
34
|
+
body: Request body bytes (None if no body)
|
|
35
|
+
signature_input_header: Signature-Input header value
|
|
36
|
+
signature_header: Signature header value
|
|
37
|
+
signature_key_header: Signature-Key header value
|
|
38
|
+
public_key: Optional public key (for hwk scheme)
|
|
39
|
+
jwks_fetcher: Optional JWKS fetcher function (for jwks/jwt schemes)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if signature is valid, False otherwise
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
SignatureError: If verification fails due to invalid format
|
|
46
|
+
"""
|
|
47
|
+
import logging
|
|
48
|
+
logger = logging.getLogger("aauth.signing")
|
|
49
|
+
|
|
50
|
+
logger.debug(f"🔐 VERIFIER: verify_signature() called")
|
|
51
|
+
logger.debug(f"🔐 VERIFIER: method={method}, target_uri={target_uri}")
|
|
52
|
+
logger.debug(f"🔐 VERIFIER: signature_input_header={signature_input_header}")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Parse Signature-Input
|
|
56
|
+
components, sig_params = parse_signature_input(signature_input_header)
|
|
57
|
+
|
|
58
|
+
# Verify created timestamp (per spec Section 10.4)
|
|
59
|
+
if "created" in sig_params:
|
|
60
|
+
created = int(sig_params["created"])
|
|
61
|
+
now = int(time.time())
|
|
62
|
+
if abs(now - created) > 60:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Parse Signature-Key
|
|
66
|
+
parsed_key = parse_signature_key(signature_key_header)
|
|
67
|
+
scheme = parsed_key["scheme"]
|
|
68
|
+
params = parsed_key["params"]
|
|
69
|
+
label = parsed_key["label"]
|
|
70
|
+
|
|
71
|
+
# Verify label consistency (per spec Section 10.1.1)
|
|
72
|
+
label_match = re.match(r'(\w+)=', signature_input_header)
|
|
73
|
+
sig_label_match = re.match(r'(\w+)=', signature_header)
|
|
74
|
+
|
|
75
|
+
if not (label_match and sig_label_match):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
if not (label_match.group(1) == sig_label_match.group(1) == label):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# Extract public key based on scheme
|
|
82
|
+
if scheme == "hwk":
|
|
83
|
+
if not public_key:
|
|
84
|
+
jwk = {
|
|
85
|
+
"kty": params.get("kty"),
|
|
86
|
+
"crv": params.get("crv"),
|
|
87
|
+
"x": params.get("x")
|
|
88
|
+
}
|
|
89
|
+
public_key = jwk_to_public_key(jwk)
|
|
90
|
+
|
|
91
|
+
elif scheme == "jwks":
|
|
92
|
+
if not jwks_fetcher:
|
|
93
|
+
raise SignatureError("sig=jwks requires jwks_fetcher")
|
|
94
|
+
|
|
95
|
+
agent_id = params.get("id")
|
|
96
|
+
kid = params.get("kid")
|
|
97
|
+
well_known = params.get("well-known")
|
|
98
|
+
jwks_param = params.get("jwks")
|
|
99
|
+
|
|
100
|
+
# Per spec Section 10.7 Mode 2: jwks parameter MUST NOT be present
|
|
101
|
+
if jwks_param:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
# Fetch JWKS
|
|
105
|
+
if callable(jwks_fetcher):
|
|
106
|
+
# Try both calling patterns
|
|
107
|
+
try:
|
|
108
|
+
jwks = jwks_fetcher(agent_id, kid) if kid else jwks_fetcher(agent_id)
|
|
109
|
+
except:
|
|
110
|
+
jwks = jwks_fetcher(agent_id)
|
|
111
|
+
else:
|
|
112
|
+
jwks = jwks_fetcher
|
|
113
|
+
|
|
114
|
+
if not jwks:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
# Find key by kid
|
|
118
|
+
keys = jwks.get("keys", [])
|
|
119
|
+
signing_key = None
|
|
120
|
+
for key in keys:
|
|
121
|
+
if key.get("kid") == kid:
|
|
122
|
+
signing_key = key
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
if not signing_key:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
public_key = jwk_to_public_key(signing_key)
|
|
129
|
+
|
|
130
|
+
elif scheme == "jwt":
|
|
131
|
+
if not jwks_fetcher:
|
|
132
|
+
raise SignatureError("sig=jwt requires jwks_fetcher")
|
|
133
|
+
|
|
134
|
+
jwt_token = params.get("jwt")
|
|
135
|
+
if not jwt_token:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
# Parse JWT to determine type
|
|
139
|
+
try:
|
|
140
|
+
header = jwt.get_unverified_header(jwt_token)
|
|
141
|
+
payload = jwt.decode(jwt_token, options={"verify_signature": False})
|
|
142
|
+
except Exception:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
typ = header.get("typ")
|
|
146
|
+
if typ not in ("agent+jwt", "auth+jwt"):
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
# Validate JWT and extract cnf.jwk
|
|
150
|
+
if typ == "agent+jwt":
|
|
151
|
+
# Verify agent token
|
|
152
|
+
try:
|
|
153
|
+
agent_claims = verify_agent_token(
|
|
154
|
+
token=jwt_token,
|
|
155
|
+
jwks_fetcher=jwks_fetcher,
|
|
156
|
+
expected_aud=None
|
|
157
|
+
)
|
|
158
|
+
cnf = agent_claims.get("cnf")
|
|
159
|
+
cnf_jwk = cnf.get("jwk") if cnf else None
|
|
160
|
+
except Exception:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
elif typ == "auth+jwt":
|
|
164
|
+
# Extract cnf.jwk from payload
|
|
165
|
+
cnf = payload.get("cnf")
|
|
166
|
+
if not cnf:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
cnf_jwk = cnf.get("jwk")
|
|
170
|
+
if not cnf_jwk:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
# Verify JWT signature using auth server's JWKS
|
|
174
|
+
iss = payload.get("iss")
|
|
175
|
+
kid_header = header.get("kid")
|
|
176
|
+
if not iss or not kid_header:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
# Fetch auth server JWKS
|
|
180
|
+
try:
|
|
181
|
+
if callable(jwks_fetcher):
|
|
182
|
+
auth_jwks = jwks_fetcher(iss)
|
|
183
|
+
else:
|
|
184
|
+
auth_jwks = jwks_fetcher
|
|
185
|
+
|
|
186
|
+
if not auth_jwks:
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# Find signing key
|
|
190
|
+
keys = auth_jwks.get("keys", [])
|
|
191
|
+
signing_key = None
|
|
192
|
+
for key in keys:
|
|
193
|
+
if key.get("kid") == kid_header:
|
|
194
|
+
signing_key = key
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
if not signing_key:
|
|
198
|
+
logger.debug(f"🔐 VERIFIER: Signing key not found in JWKS (kid={kid_header})")
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
logger.debug(f"🔐 VERIFIER: Found signing key in JWKS: {signing_key}")
|
|
202
|
+
|
|
203
|
+
# Get algorithm from JWT header
|
|
204
|
+
alg = header.get("alg")
|
|
205
|
+
if not alg:
|
|
206
|
+
logger.debug(f"🔐 VERIFIER: JWT header missing 'alg' field")
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
# Map algorithm to PyJWT algorithm names
|
|
210
|
+
# RS256 -> RS256, EdDSA -> EdDSA, etc.
|
|
211
|
+
algorithms = [alg]
|
|
212
|
+
|
|
213
|
+
logger.debug(f"🔐 VERIFIER: Verifying JWT with algorithm: {alg}")
|
|
214
|
+
logger.debug(f"🔐 VERIFIER: JWT token (first 100 chars): {jwt_token[:100]}...")
|
|
215
|
+
|
|
216
|
+
# Handle different key types
|
|
217
|
+
# For RSA keys (RS256, etc.), convert JWK to public key using PyJWT's RSA algorithm
|
|
218
|
+
# For Ed25519 keys, we need to convert using jwk_to_public_key
|
|
219
|
+
key_type = signing_key.get("kty")
|
|
220
|
+
if key_type == "RSA":
|
|
221
|
+
# Convert RSA JWK to public key object using PyJWT's RSA algorithm
|
|
222
|
+
from jwt.algorithms import RSAAlgorithm
|
|
223
|
+
auth_public_key = RSAAlgorithm.from_jwk(signing_key)
|
|
224
|
+
logger.debug(f"🔐 VERIFIER: Converted RSA JWK to public key using RSAAlgorithm.from_jwk()")
|
|
225
|
+
elif key_type == "OKP" and signing_key.get("crv") == "Ed25519":
|
|
226
|
+
# Convert Ed25519 JWK to public key
|
|
227
|
+
auth_public_key = jwk_to_public_key(signing_key)
|
|
228
|
+
logger.debug(f"🔐 VERIFIER: Converted Ed25519 JWK to public key")
|
|
229
|
+
else:
|
|
230
|
+
logger.debug(f"🔐 VERIFIER: Unsupported key type: {key_type}")
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
logger.debug(f"🔐 VERIFIER: Auth public key type: {type(auth_public_key)}")
|
|
234
|
+
|
|
235
|
+
# Verify JWT signature
|
|
236
|
+
try:
|
|
237
|
+
jwt.decode(
|
|
238
|
+
jwt_token,
|
|
239
|
+
auth_public_key,
|
|
240
|
+
algorithms=algorithms,
|
|
241
|
+
options={"verify_signature": True, "verify_exp": False, "verify_aud": False}
|
|
242
|
+
)
|
|
243
|
+
logger.debug(f"🔐 VERIFIER: JWT signature verification PASSED")
|
|
244
|
+
except Exception as jwt_error:
|
|
245
|
+
logger.debug(f"🔐 VERIFIER: JWT decode failed: {jwt_error}")
|
|
246
|
+
import traceback
|
|
247
|
+
logger.debug(f"🔐 VERIFIER: JWT decode traceback: {traceback.format_exc()}")
|
|
248
|
+
raise
|
|
249
|
+
|
|
250
|
+
# Check expiration
|
|
251
|
+
exp = payload.get("exp")
|
|
252
|
+
if exp and int(time.time()) >= exp:
|
|
253
|
+
logger.debug(f"🔐 VERIFIER: JWT expired (exp={exp}, now={int(time.time())})")
|
|
254
|
+
return False
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.debug(f"🔐 VERIFIER: JWT verification failed with exception: {e}")
|
|
257
|
+
import traceback
|
|
258
|
+
logger.debug(f"🔐 VERIFIER: Exception traceback: {traceback.format_exc()}")
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
# Convert cnf.jwk to public key for HTTPSig verification
|
|
262
|
+
public_key = jwk_to_public_key(cnf_jwk)
|
|
263
|
+
|
|
264
|
+
else:
|
|
265
|
+
raise SignatureError(f"Unknown signature scheme: {scheme}")
|
|
266
|
+
|
|
267
|
+
# Reconstruct signature base
|
|
268
|
+
parsed_uri = urlparse(target_uri)
|
|
269
|
+
authority = parsed_uri.netloc
|
|
270
|
+
path = parsed_uri.path or "/"
|
|
271
|
+
query_string = parsed_uri.query if parsed_uri.query else None
|
|
272
|
+
|
|
273
|
+
# Extract signature params (the part after "sig1=") for @signature-params line
|
|
274
|
+
# Signature-Input format: sig1=("@method" "@authority" ...);created=...
|
|
275
|
+
# RFC 9421 Section 2.5: @signature-params is required
|
|
276
|
+
signature_params = signature_input_header[5:] if signature_input_header.startswith("sig1=") else signature_input_header
|
|
277
|
+
if not signature_params:
|
|
278
|
+
return False # Invalid - signature_params required
|
|
279
|
+
|
|
280
|
+
logger.debug(f"🔐 VERIFIER: Building signature base")
|
|
281
|
+
logger.debug(f"🔐 VERIFIER: method={method}, authority={authority}, path={path}")
|
|
282
|
+
logger.debug(f"🔐 VERIFIER: covered_components={components}")
|
|
283
|
+
logger.debug(f"🔐 VERIFIER: signature_params={signature_params}")
|
|
284
|
+
logger.debug(f"🔐 VERIFIER: signature_key_header={signature_key_header[:100]}...")
|
|
285
|
+
logger.debug(f"🔐 VERIFIER: body is None: {body is None}")
|
|
286
|
+
|
|
287
|
+
signature_base = build_signature_base(
|
|
288
|
+
method=method,
|
|
289
|
+
authority=authority,
|
|
290
|
+
path=path,
|
|
291
|
+
query=query_string,
|
|
292
|
+
headers=headers,
|
|
293
|
+
body=body,
|
|
294
|
+
signature_key_header=signature_key_header,
|
|
295
|
+
covered_components=components,
|
|
296
|
+
signature_params=signature_params
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
logger.debug(f"🔐 VERIFIER SIGNATURE BASE:")
|
|
300
|
+
logger.debug(f"🔐 Signature base length: {len(signature_base)} bytes")
|
|
301
|
+
logger.debug(f"🔐 Signature base hex (first 200): {signature_base.encode('utf-8').hex()[:200]}...")
|
|
302
|
+
for i, line in enumerate(signature_base.split('\n')):
|
|
303
|
+
logger.debug(f"🔐 Line {i}: {repr(line)}")
|
|
304
|
+
|
|
305
|
+
# Parse signature
|
|
306
|
+
signature_bytes = parse_signature(signature_header, label=label)
|
|
307
|
+
|
|
308
|
+
# Verify signature
|
|
309
|
+
try:
|
|
310
|
+
public_key.verify(signature_bytes, signature_base.encode('utf-8'))
|
|
311
|
+
logger.debug(f"🔐 VERIFIER: ✅ Signature verification PASSED")
|
|
312
|
+
return True
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.debug(f"🔐 VERIFIER: ❌ Signature verification FAILED: {e}")
|
|
315
|
+
import traceback
|
|
316
|
+
logger.debug(f"🔐 VERIFIER: Exception traceback: {traceback.format_exc()}")
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
except SignatureError:
|
|
320
|
+
raise
|
|
321
|
+
except Exception as e:
|
|
322
|
+
raise SignatureError(f"Signature verification failed: {e}") from e
|
|
323
|
+
|
aauth/tokens/__init__.py
ADDED