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