aauth-signing 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.
- aauth_signing-0.1.0/PKG-INFO +44 -0
- aauth_signing-0.1.0/README.md +26 -0
- aauth_signing-0.1.0/aauth_signing/__init__.py +60 -0
- aauth_signing-0.1.0/aauth_signing/algorithms.py +34 -0
- aauth_signing-0.1.0/aauth_signing/errors.py +29 -0
- aauth_signing-0.1.0/aauth_signing/keys/__init__.py +1 -0
- aauth_signing-0.1.0/aauth_signing/keys/jwk.py +122 -0
- aauth_signing-0.1.0/aauth_signing/keys/keypair.py +15 -0
- aauth_signing-0.1.0/aauth_signing/signature.py +52 -0
- aauth_signing-0.1.0/aauth_signing/signature_base.py +186 -0
- aauth_signing-0.1.0/aauth_signing/signature_input.py +62 -0
- aauth_signing-0.1.0/aauth_signing/signature_key.py +112 -0
- aauth_signing-0.1.0/aauth_signing/signer.py +126 -0
- aauth_signing-0.1.0/aauth_signing/tokens/__init__.py +1 -0
- aauth_signing-0.1.0/aauth_signing/tokens/agent_token.py +186 -0
- aauth_signing-0.1.0/aauth_signing/verifier.py +324 -0
- aauth_signing-0.1.0/aauth_signing.egg-info/PKG-INFO +44 -0
- aauth_signing-0.1.0/aauth_signing.egg-info/SOURCES.txt +21 -0
- aauth_signing-0.1.0/aauth_signing.egg-info/dependency_links.txt +1 -0
- aauth_signing-0.1.0/aauth_signing.egg-info/requires.txt +2 -0
- aauth_signing-0.1.0/aauth_signing.egg-info/top_level.txt +1 -0
- aauth_signing-0.1.0/pyproject.toml +29 -0
- aauth_signing-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aauth-signing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP Message Signing (RFC 9421 + draft-hardt-httpbis-signature-key) for AAuth
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Development Status :: 4 - Beta
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: cryptography>=41.0.0
|
|
17
|
+
Requires-Dist: PyJWT>=2.8.0
|
|
18
|
+
|
|
19
|
+
# aauth-signing
|
|
20
|
+
|
|
21
|
+
HTTP Message Signatures ([RFC 9421](https://www.rfc-editor.org/rfc/rfc9421)) and the **Signature-Key** header ([draft-hardt-httpbis-signature-key](https://datatracker.ietf.org/doc/draft-hardt-httpbis-signature-key/)) as used by [AAuth](https://github.com/ietf-wg-aauth).
|
|
22
|
+
|
|
23
|
+
This package is **AAuth-oriented** (e.g. `aa-agent+jwt` / `aa-auth+jwt` in the `jwt` signature scheme, optional `aauth-mission` covered component).
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install aauth-signing
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from aauth_signing import sign_request, verify_signature
|
|
35
|
+
from aauth_signing import build_signature_key_header, parse_signature_key
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Development
|
|
39
|
+
|
|
40
|
+
From the repository root:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e ./aauth-signing
|
|
44
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# aauth-signing
|
|
2
|
+
|
|
3
|
+
HTTP Message Signatures ([RFC 9421](https://www.rfc-editor.org/rfc/rfc9421)) and the **Signature-Key** header ([draft-hardt-httpbis-signature-key](https://datatracker.ietf.org/doc/draft-hardt-httpbis-signature-key/)) as used by [AAuth](https://github.com/ietf-wg-aauth).
|
|
4
|
+
|
|
5
|
+
This package is **AAuth-oriented** (e.g. `aa-agent+jwt` / `aa-auth+jwt` in the `jwt` signature scheme, optional `aauth-mission` covered component).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install aauth-signing
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from aauth_signing import sign_request, verify_signature
|
|
17
|
+
from aauth_signing import build_signature_key_header, parse_signature_key
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Development
|
|
21
|
+
|
|
22
|
+
From the repository root:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e ./aauth-signing
|
|
26
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""HTTP Message Signing layer (RFC 9421 + draft-hardt-httpbis-signature-key).
|
|
2
|
+
|
|
3
|
+
This package provides AAuth-oriented HTTP message signing:
|
|
4
|
+
|
|
5
|
+
- HTTP Message Signatures per RFC 9421
|
|
6
|
+
- Signature-Key header per draft-hardt-httpbis-signature-key
|
|
7
|
+
(schemes: hwk, jwks_uri, jwt, jkt-jwt)
|
|
8
|
+
|
|
9
|
+
Typical usage::
|
|
10
|
+
|
|
11
|
+
from aauth_signing import sign_request, verify_signature
|
|
12
|
+
from aauth_signing import build_signature_key_header, parse_signature_key
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .signer import sign_request
|
|
16
|
+
from .verifier import verify_signature
|
|
17
|
+
from .algorithms import (
|
|
18
|
+
ED25519,
|
|
19
|
+
RSA_PSS_SHA512,
|
|
20
|
+
RSA_PSS_SHA256,
|
|
21
|
+
ECDSA_P256_SHA256,
|
|
22
|
+
ECDSA_P384_SHA384,
|
|
23
|
+
SUPPORTED_ALGORITHMS,
|
|
24
|
+
is_supported,
|
|
25
|
+
)
|
|
26
|
+
from .signature_base import (
|
|
27
|
+
build_signature_base,
|
|
28
|
+
build_signature_params,
|
|
29
|
+
calculate_content_digest,
|
|
30
|
+
)
|
|
31
|
+
from .signature_key import build_signature_key_header, parse_signature_key
|
|
32
|
+
from .signature_input import build_signature_input_header, parse_signature_input
|
|
33
|
+
from .signature import build_signature_header, parse_signature
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Core sign/verify
|
|
37
|
+
"sign_request",
|
|
38
|
+
"verify_signature",
|
|
39
|
+
# Algorithms
|
|
40
|
+
"ED25519",
|
|
41
|
+
"RSA_PSS_SHA512",
|
|
42
|
+
"RSA_PSS_SHA256",
|
|
43
|
+
"ECDSA_P256_SHA256",
|
|
44
|
+
"ECDSA_P384_SHA384",
|
|
45
|
+
"SUPPORTED_ALGORITHMS",
|
|
46
|
+
"is_supported",
|
|
47
|
+
# Signature base (RFC 9421)
|
|
48
|
+
"build_signature_base",
|
|
49
|
+
"build_signature_params",
|
|
50
|
+
"calculate_content_digest",
|
|
51
|
+
# Signature-Key header (draft-hardt-httpbis-signature-key)
|
|
52
|
+
"build_signature_key_header",
|
|
53
|
+
"parse_signature_key",
|
|
54
|
+
# Signature-Input header (RFC 9421)
|
|
55
|
+
"build_signature_input_header",
|
|
56
|
+
"parse_signature_input",
|
|
57
|
+
# Signature header (RFC 9421)
|
|
58
|
+
"build_signature_header",
|
|
59
|
+
"parse_signature",
|
|
60
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Signature algorithm support for AAuth."""
|
|
2
|
+
|
|
3
|
+
# Supported algorithms per AAuth spec Section 10.2
|
|
4
|
+
# MUST support ed25519, MAY support others
|
|
5
|
+
|
|
6
|
+
ED25519 = "ed25519"
|
|
7
|
+
RSA_PSS_SHA512 = "rsa-pss-sha512"
|
|
8
|
+
RSA_PSS_SHA256 = "rsa-pss-sha256"
|
|
9
|
+
ECDSA_P256_SHA256 = "ecdsa-p256-sha256"
|
|
10
|
+
ECDSA_P384_SHA384 = "ecdsa-p384-sha384"
|
|
11
|
+
|
|
12
|
+
# Required algorithm
|
|
13
|
+
REQUIRED_ALGORITHM = ED25519
|
|
14
|
+
|
|
15
|
+
# All supported algorithms
|
|
16
|
+
SUPPORTED_ALGORITHMS = [
|
|
17
|
+
ED25519,
|
|
18
|
+
RSA_PSS_SHA512,
|
|
19
|
+
RSA_PSS_SHA256,
|
|
20
|
+
ECDSA_P256_SHA256,
|
|
21
|
+
ECDSA_P384_SHA384,
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_supported(algorithm: str) -> bool:
|
|
26
|
+
"""Check if algorithm is supported.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
algorithm: Algorithm name
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
True if supported, False otherwise
|
|
33
|
+
"""
|
|
34
|
+
return algorithm.lower() in [alg.lower() for alg in SUPPORTED_ALGORITHMS]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exceptions shared by the aauth-signing package."""
|
|
2
|
+
|
|
3
|
+
# Signature-Error header codes (401 responses, per draft-hardt-httpbis-signature-key)
|
|
4
|
+
ERROR_INVALID_SIGNATURE = "invalid_signature"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AAuthError(Exception):
|
|
8
|
+
"""Base exception for AAuth-related errors in this package."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SignatureError(AAuthError):
|
|
14
|
+
"""HTTP signature validation or creation error."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, error_code: str = None, details: dict = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.error_code = error_code or ERROR_INVALID_SIGNATURE
|
|
19
|
+
self.details = details or {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TokenError(AAuthError):
|
|
23
|
+
"""Token validation or creation error."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, token_type: str = None, error_code: str = None, details: dict = None):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.token_type = token_type
|
|
28
|
+
self.error_code = error_code
|
|
29
|
+
self.details = details or {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""JWK and keypair helpers for aauth-signing."""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""JWK (JSON Web Key) operations for aauth-signing."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from typing import Dict, Any, Optional
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def private_key_to_jwk(private_key: Ed25519PrivateKey, kid: Optional[str] = None) -> Dict[str, Any]:
|
|
12
|
+
"""Convert Ed25519 private key to JWK format.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
private_key: Ed25519 private key
|
|
16
|
+
kid: Optional key ID
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
JWK dictionary
|
|
20
|
+
"""
|
|
21
|
+
public_key = private_key.public_key()
|
|
22
|
+
return public_key_to_jwk(public_key, kid)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def public_key_to_jwk(public_key: Ed25519PublicKey, kid: Optional[str] = None) -> Dict[str, Any]:
|
|
26
|
+
"""Convert Ed25519 public key to JWK format.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
public_key: Ed25519 public key
|
|
30
|
+
kid: Optional key ID
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
JWK dictionary
|
|
34
|
+
"""
|
|
35
|
+
public_bytes = public_key.public_bytes(
|
|
36
|
+
encoding=serialization.Encoding.Raw,
|
|
37
|
+
format=serialization.PublicFormat.Raw
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Ed25519 public key is 32 bytes, encode as base64url
|
|
41
|
+
x = base64.urlsafe_b64encode(public_bytes).decode('utf-8').rstrip('=')
|
|
42
|
+
|
|
43
|
+
jwk = {
|
|
44
|
+
"kty": "OKP",
|
|
45
|
+
"crv": "Ed25519",
|
|
46
|
+
"x": x
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if kid:
|
|
50
|
+
jwk["kid"] = kid
|
|
51
|
+
|
|
52
|
+
return jwk
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def jwk_to_public_key(jwk: Dict[str, Any]) -> Ed25519PublicKey:
|
|
56
|
+
"""Convert JWK to Ed25519PublicKey object.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
jwk: JWK dictionary with kty="OKP", crv="Ed25519"
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Ed25519PublicKey object
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If JWK is not Ed25519 format
|
|
66
|
+
"""
|
|
67
|
+
if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519":
|
|
68
|
+
raise ValueError("JWK must be Ed25519 (OKP, Ed25519)")
|
|
69
|
+
|
|
70
|
+
x = jwk["x"]
|
|
71
|
+
# Add padding if needed
|
|
72
|
+
x += '=' * (4 - len(x) % 4)
|
|
73
|
+
public_bytes = base64.urlsafe_b64decode(x)
|
|
74
|
+
|
|
75
|
+
return Ed25519PublicKey.from_public_bytes(public_bytes)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def calculate_jwk_thumbprint(jwk: Dict[str, Any]) -> str:
|
|
79
|
+
"""Calculate JWK Thumbprint per RFC 7638.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
jwk: JWK dictionary (must be canonical - only include required fields)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Base64url-encoded SHA-256 hash of canonical JWK JSON
|
|
86
|
+
"""
|
|
87
|
+
# Create canonical JWK (only include required fields, sorted)
|
|
88
|
+
# For Ed25519: kty, crv, x (kid excluded from thumbprint)
|
|
89
|
+
canonical_jwk = {}
|
|
90
|
+
|
|
91
|
+
# Required fields in order
|
|
92
|
+
if "kty" in jwk:
|
|
93
|
+
canonical_jwk["kty"] = jwk["kty"]
|
|
94
|
+
if "crv" in jwk:
|
|
95
|
+
canonical_jwk["crv"] = jwk["crv"]
|
|
96
|
+
if "x" in jwk:
|
|
97
|
+
canonical_jwk["x"] = jwk["x"]
|
|
98
|
+
|
|
99
|
+
# Convert to canonical JSON (no spaces, sorted keys)
|
|
100
|
+
canonical_json = json.dumps(canonical_jwk, separators=(',', ':'), sort_keys=True)
|
|
101
|
+
|
|
102
|
+
# SHA-256 hash
|
|
103
|
+
hash_bytes = hashlib.sha256(canonical_json.encode('utf-8')).digest()
|
|
104
|
+
|
|
105
|
+
# Base64url encode (no padding)
|
|
106
|
+
thumbprint = base64.urlsafe_b64encode(hash_bytes).decode('utf-8').rstrip('=')
|
|
107
|
+
|
|
108
|
+
return thumbprint
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def generate_jwks(keys: list[Dict[str, Any]]) -> Dict[str, Any]:
|
|
112
|
+
"""Generate a JWKS document from a list of JWKs.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
keys: List of JWK dictionaries
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
JWKS document dictionary
|
|
119
|
+
"""
|
|
120
|
+
return {
|
|
121
|
+
"keys": keys
|
|
122
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Key pair generation for aauth-signing."""
|
|
2
|
+
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_ed25519_keypair() -> Tuple[Ed25519PrivateKey, Ed25519PublicKey]:
|
|
8
|
+
"""Generate a new Ed25519 key pair.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
Tuple of (private_key, public_key)
|
|
12
|
+
"""
|
|
13
|
+
private_key = Ed25519PrivateKey.generate()
|
|
14
|
+
public_key = private_key.public_key()
|
|
15
|
+
return private_key, public_key
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Signature header parsing and building (RFC 9421)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import base64
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_signature_header(signature_bytes: bytes, label: str = "sig") -> str:
|
|
9
|
+
"""Build Signature header per RFC 9421 Section 4.2.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
signature_bytes: Raw signature bytes
|
|
13
|
+
label: Signature label (default: "sig")
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Signature header value
|
|
17
|
+
"""
|
|
18
|
+
signature_b64 = base64.urlsafe_b64encode(signature_bytes).decode('utf-8').rstrip('=')
|
|
19
|
+
return f'{label}=:{signature_b64}:'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_signature(header_value: str, label: Optional[str] = None) -> bytes:
|
|
23
|
+
"""Parse Signature header to extract signature bytes.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
header_value: Signature header value
|
|
27
|
+
label: Optional expected label (for validation)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Signature bytes
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If header format is invalid or label doesn't match
|
|
34
|
+
"""
|
|
35
|
+
match = re.search(r'(\w+)=:([A-Za-z0-9_-]+):', header_value)
|
|
36
|
+
if not match:
|
|
37
|
+
raise ValueError(f"Invalid Signature format: {header_value}")
|
|
38
|
+
|
|
39
|
+
found_label = match.group(1)
|
|
40
|
+
signature_b64 = match.group(2)
|
|
41
|
+
|
|
42
|
+
if label and found_label != label:
|
|
43
|
+
raise ValueError(f"Label mismatch: expected {label}, got {found_label}")
|
|
44
|
+
|
|
45
|
+
# Add padding if needed
|
|
46
|
+
signature_b64 += '=' * (4 - len(signature_b64) % 4)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
signature_bytes = base64.urlsafe_b64decode(signature_b64)
|
|
50
|
+
return signature_bytes
|
|
51
|
+
except Exception as e:
|
|
52
|
+
raise ValueError(f"Failed to decode signature: {e}")
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Signature base construction for HTTP Message Signing (RFC 9421)."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Tuple, Dict, Optional
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"build_signature_base",
|
|
7
|
+
"build_signature_params",
|
|
8
|
+
"calculate_content_digest",
|
|
9
|
+
"_determine_covered_components",
|
|
10
|
+
]
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_signature_base(
|
|
16
|
+
method: str,
|
|
17
|
+
authority: str,
|
|
18
|
+
path: str,
|
|
19
|
+
query: Optional[str],
|
|
20
|
+
headers: Dict[str, str],
|
|
21
|
+
body: Optional[bytes],
|
|
22
|
+
signature_key_header: str,
|
|
23
|
+
covered_components: Optional[List[str]] = None,
|
|
24
|
+
signature_params: Optional[str] = None
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Build signature base string per RFC 9421 Section 2.5.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
method: HTTP method
|
|
30
|
+
authority: Canonical authority (host:port)
|
|
31
|
+
path: Request path
|
|
32
|
+
query: Query string (without leading "?")
|
|
33
|
+
headers: Request headers
|
|
34
|
+
body: Request body bytes (None if no body)
|
|
35
|
+
signature_key_header: Signature-Key header value
|
|
36
|
+
covered_components: Optional list of components to cover (auto-detected if None)
|
|
37
|
+
signature_params: Signature-Input header value (required for @signature-params line)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Signature base string
|
|
41
|
+
"""
|
|
42
|
+
if covered_components is None:
|
|
43
|
+
covered_components = _determine_covered_components(query, body, additional_components=None)
|
|
44
|
+
|
|
45
|
+
components: List[Tuple[str, str]] = []
|
|
46
|
+
|
|
47
|
+
for component_name in covered_components:
|
|
48
|
+
if component_name == "@method":
|
|
49
|
+
components.append(("@method", method))
|
|
50
|
+
elif component_name == "@authority":
|
|
51
|
+
components.append(("@authority", authority))
|
|
52
|
+
elif component_name == "@path":
|
|
53
|
+
components.append(("@path", path))
|
|
54
|
+
elif component_name == "@query":
|
|
55
|
+
if query:
|
|
56
|
+
components.append(("@query", f"?{query}"))
|
|
57
|
+
else:
|
|
58
|
+
raise ValueError("@query component specified but no query string present")
|
|
59
|
+
elif component_name == "content-type":
|
|
60
|
+
if body:
|
|
61
|
+
content_type = _get_header(headers, "content-type")
|
|
62
|
+
if content_type:
|
|
63
|
+
components.append(("content-type", content_type))
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError("content-type component required but header missing")
|
|
66
|
+
else:
|
|
67
|
+
raise ValueError("content-type component specified but no body present")
|
|
68
|
+
elif component_name == "content-digest":
|
|
69
|
+
if body:
|
|
70
|
+
content_digest = _get_header(headers, "content-digest")
|
|
71
|
+
if content_digest:
|
|
72
|
+
components.append(("content-digest", content_digest))
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError("content-digest component required but header missing")
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError("content-digest component specified but no body present")
|
|
77
|
+
elif component_name == "signature-key":
|
|
78
|
+
components.append(("signature-key", signature_key_header))
|
|
79
|
+
elif component_name == "aauth-mission":
|
|
80
|
+
mission_val = _get_header(headers, "aauth-mission")
|
|
81
|
+
if not mission_val:
|
|
82
|
+
raise ValueError("aauth-mission in Signature-Input but AAuth-Mission header missing")
|
|
83
|
+
components.append(("aauth-mission", mission_val))
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(f"Unknown component: {component_name}")
|
|
86
|
+
|
|
87
|
+
# Build signature base (RFC 9421 Section 2.5)
|
|
88
|
+
signature_base_parts = []
|
|
89
|
+
for component_name, component_value in components:
|
|
90
|
+
if component_name.startswith("@"):
|
|
91
|
+
signature_base_parts.append(f'"{component_name}": {component_value}')
|
|
92
|
+
else:
|
|
93
|
+
header_name = component_name.lower()
|
|
94
|
+
signature_base_parts.append(f'"{header_name}": {component_value}')
|
|
95
|
+
|
|
96
|
+
# Add @signature-params as the FINAL line (RFC 9421 Section 2.5)
|
|
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
|
+
return "\n".join(signature_base_parts)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _determine_covered_components(
|
|
105
|
+
query: Optional[str],
|
|
106
|
+
body: Optional[bytes],
|
|
107
|
+
additional_components: Optional[List[str]] = None,
|
|
108
|
+
*,
|
|
109
|
+
include_aauth_mission: bool = False,
|
|
110
|
+
) -> List[str]:
|
|
111
|
+
"""Determine covered components based on request structure.
|
|
112
|
+
|
|
113
|
+
Per AAuth spec Section 15.3, MUST cover:
|
|
114
|
+
- @method, @authority, @path, signature-key
|
|
115
|
+
|
|
116
|
+
With ``AAuth-Mission`` on authorization requests, also cover ``aauth-mission`` after
|
|
117
|
+
``signature-key`` (spec §Authorization Endpoint Request).
|
|
118
|
+
|
|
119
|
+
Resources MAY require additional components via additional_signature_components.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
query: Query string (None if no query)
|
|
123
|
+
body: Request body (None if no body)
|
|
124
|
+
additional_components: Optional list of additional components to include
|
|
125
|
+
include_aauth_mission: If True, append ``aauth-mission`` after ``signature-key``.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of component names
|
|
129
|
+
"""
|
|
130
|
+
components = ["@method", "@authority", "@path"]
|
|
131
|
+
|
|
132
|
+
if query:
|
|
133
|
+
components.append("@query")
|
|
134
|
+
|
|
135
|
+
if additional_components:
|
|
136
|
+
components.extend(additional_components)
|
|
137
|
+
|
|
138
|
+
# signature-key MUST always be included
|
|
139
|
+
components.append("signature-key")
|
|
140
|
+
|
|
141
|
+
if include_aauth_mission:
|
|
142
|
+
components.append("aauth-mission")
|
|
143
|
+
|
|
144
|
+
return components
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _get_header(headers: Dict[str, str], name: str) -> Optional[str]:
|
|
148
|
+
"""Get header value (case-insensitive)."""
|
|
149
|
+
name_lower = name.lower()
|
|
150
|
+
for key, value in headers.items():
|
|
151
|
+
if key.lower() == name_lower:
|
|
152
|
+
return value
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_signature_params(
|
|
157
|
+
covered_components: List[str],
|
|
158
|
+
created: int
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Build the Signature-Input value (the part after the label).
|
|
161
|
+
|
|
162
|
+
Per AAuth spec Section 15.4, only `created` is REQUIRED.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
covered_components: List of component names
|
|
166
|
+
created: Creation timestamp (Unix time)
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Signature params string: ("@method" "@authority" ...);created=1234567890
|
|
170
|
+
"""
|
|
171
|
+
components_str = " ".join(f'"{c}"' for c in covered_components)
|
|
172
|
+
return f"({components_str});created={created}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def calculate_content_digest(body: bytes) -> str:
|
|
176
|
+
"""Calculate Content-Digest header value per RFC 9530.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
body: Request body bytes
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Content-Digest header value (e.g., "sha-256=:...:")
|
|
183
|
+
"""
|
|
184
|
+
digest = hashlib.sha256(body).digest()
|
|
185
|
+
digest_b64 = base64.b64encode(digest).decode('ascii')
|
|
186
|
+
return f"sha-256=:{digest_b64}:"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Signature-Input header parsing and building (RFC 9421)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_signature_input_header(
|
|
9
|
+
covered_components: List[str],
|
|
10
|
+
label: str = "sig",
|
|
11
|
+
created: Optional[int] = None
|
|
12
|
+
) -> str:
|
|
13
|
+
"""Build Signature-Input header per RFC 9421 Section 4.1.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
covered_components: List of component names to cover
|
|
17
|
+
label: Signature label (default: "sig")
|
|
18
|
+
created: Creation timestamp (Unix time). If None, uses current time.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Signature-Input header value
|
|
22
|
+
"""
|
|
23
|
+
if created is None:
|
|
24
|
+
created = int(time.time())
|
|
25
|
+
|
|
26
|
+
component_list = ' '.join([
|
|
27
|
+
f'"{comp}"' for comp in covered_components
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
return f'{label}=({component_list});created={created}'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_signature_input(header_value: str) -> tuple[List[str], Dict[str, Any]]:
|
|
34
|
+
"""Parse Signature-Input header to extract covered components and parameters.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
header_value: Signature-Input header value
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple of (components list, parameters dict)
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If header format is invalid
|
|
44
|
+
"""
|
|
45
|
+
match = re.match(r'(\w+)=\((.*)\)(?:;(.*))?', header_value)
|
|
46
|
+
if not match:
|
|
47
|
+
raise ValueError(f"Invalid Signature-Input format: {header_value}")
|
|
48
|
+
|
|
49
|
+
components_str = match.group(2)
|
|
50
|
+
params_str = match.group(3) or ""
|
|
51
|
+
|
|
52
|
+
components = []
|
|
53
|
+
for match in re.finditer(r'"([^"]+)"', components_str):
|
|
54
|
+
components.append(match.group(1))
|
|
55
|
+
|
|
56
|
+
params = {}
|
|
57
|
+
for match in re.finditer(r'(\w+)=([^\s;]+)', params_str):
|
|
58
|
+
key = match.group(1)
|
|
59
|
+
value = match.group(2).strip('"')
|
|
60
|
+
params[key] = value
|
|
61
|
+
|
|
62
|
+
return components, params
|