openkitx403-client 0.1.2__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.
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""OpenKitx403 Python Client - Manual wallet authentication for scripts and agents"""
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
import secrets
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Dict, Optional, Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
import base58
|
|
13
|
+
from solders.keypair import Keypair # ✅ CHANGED: solana → solders
|
|
14
|
+
from nacl.signing import SigningKey
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenKit403ClientError(Exception):
|
|
19
|
+
"""Base exception for OpenKit403 client errors"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OpenKit403Client:
|
|
25
|
+
"""
|
|
26
|
+
Python client for OpenKitx403 wallet authentication.
|
|
27
|
+
|
|
28
|
+
This client is designed for server-side Python applications and scripts
|
|
29
|
+
that need to authenticate with OpenKitx403-protected APIs using a Solana keypair.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
from solders.keypair import Keypair # ✅ CHANGED
|
|
33
|
+
from openkitx403_client import OpenKit403Client
|
|
34
|
+
|
|
35
|
+
keypair = Keypair.generate()
|
|
36
|
+
client = OpenKit403Client(keypair)
|
|
37
|
+
|
|
38
|
+
response = client.authenticate('https://api.example.com/protected')
|
|
39
|
+
print(response.json())
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, keypair: Keypair):
|
|
43
|
+
"""
|
|
44
|
+
Initialize client with a Solana keypair.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
keypair: Solana keypair for signing challenges
|
|
48
|
+
"""
|
|
49
|
+
self.keypair = keypair
|
|
50
|
+
self.address = str(keypair.pubkey()) # ✅ CHANGED: public_key → pubkey()
|
|
51
|
+
|
|
52
|
+
def authenticate(
|
|
53
|
+
self,
|
|
54
|
+
url: str,
|
|
55
|
+
method: str = 'GET',
|
|
56
|
+
headers: Optional[Dict[str, str]] = None,
|
|
57
|
+
data: Optional[Dict[str, Any]] = None,
|
|
58
|
+
json_data: Optional[Dict[str, Any]] = None
|
|
59
|
+
) -> requests.Response:
|
|
60
|
+
"""
|
|
61
|
+
Authenticate with an OpenKitx403-protected endpoint.
|
|
62
|
+
|
|
63
|
+
This method handles the complete authentication flow:
|
|
64
|
+
1. Makes initial request
|
|
65
|
+
2. If 403, extracts challenge
|
|
66
|
+
3. Signs challenge with keypair
|
|
67
|
+
4. Retries request with Authorization header
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
url: API endpoint URL
|
|
71
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
72
|
+
headers: Additional headers to send
|
|
73
|
+
data: Form data to send (for POST/PUT)
|
|
74
|
+
json_data: JSON data to send (for POST/PUT)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
requests.Response object from the authenticated request
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
OpenKit403ClientError: If authentication fails
|
|
81
|
+
"""
|
|
82
|
+
headers = headers or {}
|
|
83
|
+
|
|
84
|
+
# Step 1: Initial request
|
|
85
|
+
response = self._make_request(url, method, headers, data, json_data)
|
|
86
|
+
|
|
87
|
+
# Step 2: Check if we got a 403 challenge
|
|
88
|
+
if response.status_code == 403:
|
|
89
|
+
www_auth = response.headers.get('WWW-Authenticate', '')
|
|
90
|
+
|
|
91
|
+
if not www_auth.startswith('OpenKitx403'):
|
|
92
|
+
raise OpenKit403ClientError(
|
|
93
|
+
f"Expected OpenKitx403 challenge, got: {www_auth[:50]}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Extract challenge
|
|
97
|
+
challenge_b64 = self._extract_challenge(www_auth)
|
|
98
|
+
if not challenge_b64:
|
|
99
|
+
raise OpenKit403ClientError("No challenge found in WWW-Authenticate header")
|
|
100
|
+
|
|
101
|
+
# Step 3: Sign challenge
|
|
102
|
+
signature_b58 = self._sign_challenge(challenge_b64)
|
|
103
|
+
|
|
104
|
+
# Step 4: Build Authorization header
|
|
105
|
+
auth_header = self._build_authorization(
|
|
106
|
+
challenge_b64,
|
|
107
|
+
signature_b58,
|
|
108
|
+
method,
|
|
109
|
+
urlparse(url).path
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Step 5: Retry with Authorization
|
|
113
|
+
headers['Authorization'] = auth_header
|
|
114
|
+
response = self._make_request(url, method, headers, data, json_data)
|
|
115
|
+
|
|
116
|
+
return response
|
|
117
|
+
|
|
118
|
+
def _make_request(
|
|
119
|
+
self,
|
|
120
|
+
url: str,
|
|
121
|
+
method: str,
|
|
122
|
+
headers: Dict[str, str],
|
|
123
|
+
data: Optional[Dict[str, Any]],
|
|
124
|
+
json_data: Optional[Dict[str, Any]]
|
|
125
|
+
) -> requests.Response:
|
|
126
|
+
"""Make HTTP request"""
|
|
127
|
+
return requests.request(
|
|
128
|
+
method=method,
|
|
129
|
+
url=url,
|
|
130
|
+
headers=headers,
|
|
131
|
+
data=data,
|
|
132
|
+
json=json_data
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _extract_challenge(self, www_authenticate: str) -> Optional[str]:
|
|
136
|
+
"""Extract base64url-encoded challenge from WWW-Authenticate header"""
|
|
137
|
+
match = re.search(r'challenge="([^"]+)"', www_authenticate)
|
|
138
|
+
return match.group(1) if match else None
|
|
139
|
+
|
|
140
|
+
def _sign_challenge(self, challenge_b64: str) -> str:
|
|
141
|
+
"""
|
|
142
|
+
Sign a challenge and return base58-encoded signature.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
challenge_b64: Base64url-encoded challenge from server
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Base58-encoded signature
|
|
149
|
+
"""
|
|
150
|
+
# Decode challenge
|
|
151
|
+
challenge_json = self._base64url_decode(challenge_b64)
|
|
152
|
+
challenge = json.loads(challenge_json)
|
|
153
|
+
|
|
154
|
+
# Build signing string
|
|
155
|
+
signing_string = self._build_signing_string(challenge)
|
|
156
|
+
|
|
157
|
+
# Sign with keypair
|
|
158
|
+
message = signing_string.encode('utf-8')
|
|
159
|
+
signing_key = SigningKey(bytes(self.keypair.secret())) # ✅ CHANGED: secret_key[:32] → secret()
|
|
160
|
+
signed = signing_key.sign(message)
|
|
161
|
+
|
|
162
|
+
# Return base58-encoded signature
|
|
163
|
+
return base58.b58encode(signed.signature).decode('ascii')
|
|
164
|
+
|
|
165
|
+
def _build_signing_string(self, challenge: Dict[str, Any]) -> str:
|
|
166
|
+
"""Build canonical signing string from challenge"""
|
|
167
|
+
# Sort challenge keys for deterministic JSON
|
|
168
|
+
payload = json.dumps(challenge, sort_keys=True)
|
|
169
|
+
|
|
170
|
+
lines = [
|
|
171
|
+
'OpenKitx403 Challenge',
|
|
172
|
+
'',
|
|
173
|
+
f'domain: {challenge["aud"]}',
|
|
174
|
+
f'server: {challenge["serverId"]}',
|
|
175
|
+
f'nonce: {challenge["nonce"]}',
|
|
176
|
+
f'ts: {challenge["ts"]}',
|
|
177
|
+
f'method: {challenge["method"]}',
|
|
178
|
+
f'path: {challenge["path"]}',
|
|
179
|
+
'',
|
|
180
|
+
f'payload: {payload}'
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
return '\n'.join(lines)
|
|
184
|
+
|
|
185
|
+
def _build_authorization(
|
|
186
|
+
self,
|
|
187
|
+
challenge_b64: str,
|
|
188
|
+
signature_b58: str,
|
|
189
|
+
method: str,
|
|
190
|
+
path: str
|
|
191
|
+
) -> str:
|
|
192
|
+
"""Build Authorization header value"""
|
|
193
|
+
nonce = self._generate_nonce()
|
|
194
|
+
ts = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
195
|
+
bind = f'{method}:{path}'
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
f'OpenKitx403 '
|
|
199
|
+
f'addr="{self.address}", '
|
|
200
|
+
f'sig="{signature_b58}", '
|
|
201
|
+
f'challenge="{challenge_b64}", '
|
|
202
|
+
f'ts="{ts}", '
|
|
203
|
+
f'nonce="{nonce}", '
|
|
204
|
+
f'bind="{bind}"'
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _generate_nonce() -> str:
|
|
209
|
+
"""Generate cryptographically random nonce"""
|
|
210
|
+
return base58.b58encode(secrets.token_bytes(16)).decode('ascii')
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _base64url_decode(s: str) -> str:
|
|
214
|
+
"""Decode base64url string"""
|
|
215
|
+
# Add padding
|
|
216
|
+
padding = (4 - len(s) % 4) % 4
|
|
217
|
+
s_padded = s + '=' * padding
|
|
218
|
+
|
|
219
|
+
decoded = base64.urlsafe_b64decode(s_padded)
|
|
220
|
+
return decoded.decode('utf-8')
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def create_client(keypair: Keypair) -> OpenKit403Client:
|
|
225
|
+
"""
|
|
226
|
+
Factory function to create an OpenKit403Client.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
keypair: Solana keypair for authentication
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Configured OpenKit403Client instance
|
|
233
|
+
"""
|
|
234
|
+
return OpenKit403Client(keypair)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
__all__ = [
|
|
239
|
+
'OpenKit403Client',
|
|
240
|
+
'OpenKit403ClientError',
|
|
241
|
+
'create_client'
|
|
242
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openkitx403-client
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Python client SDK for OpenKitx403 wallet authentication
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: OpenKitx403 Contributors
|
|
7
|
+
Author-email: team@openkitx403.dev
|
|
8
|
+
Requires-Python: >=3.8,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Dist: base58 (>=2.1.1,<3.0.0)
|
|
19
|
+
Requires-Dist: pynacl (>=1.5.0,<2.0.0)
|
|
20
|
+
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
21
|
+
Requires-Dist: solders (>=0.18.1,<0.19.0)
|
|
22
|
+
Project-URL: Documentation, https://openkitx403.github.io/openkitx403-docs
|
|
23
|
+
Project-URL: Homepage, https://www.openkitx403.dev
|
|
24
|
+
Project-URL: Repository, https://github.com/openkitx403/openkitx403
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# openkitx403-client (Python)
|
|
28
|
+
|
|
29
|
+
Python client SDK for OpenKitx403 wallet authentication.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
\`\`\`bash
|
|
34
|
+
pip install openkitx403-client
|
|
35
|
+
\`\`\`
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
\`\`\`python
|
|
40
|
+
from solana.keypair import Keypair
|
|
41
|
+
from openkitx403_client import OpenKit403Client
|
|
42
|
+
|
|
43
|
+
keypair = Keypair.generate()
|
|
44
|
+
client = OpenKit403Client(keypair)
|
|
45
|
+
|
|
46
|
+
response = client.authenticate('https://api.example.com/protected')
|
|
47
|
+
print(response.json())
|
|
48
|
+
\`\`\`
|
|
49
|
+
|
|
50
|
+
See USAGE_EXAMPLES.md for complete examples.
|
|
51
|
+
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
openkitx403_client/__init__.py,sha256=0MbpgQgGzlZk2L3ZhvAwpEjtPNK5oVgRFN1cTGTmytM,7566
|
|
2
|
+
openkitx403_client-0.1.2.dist-info/METADATA,sha256=PSmBGti8TuWswwWc9iAV9b_BUZdy88rrt-dxAOjXYz0,1560
|
|
3
|
+
openkitx403_client-0.1.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
4
|
+
openkitx403_client-0.1.2.dist-info/RECORD,,
|