phable 0.1.4__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.
phable-0.1.4/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Rick Jennings
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
phable-0.1.4/PKG-INFO ADDED
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.1
2
+ Name: phable
3
+ Version: 0.1.4
4
+ Summary:
5
+ License: MIT
6
+ Author: Rick Jennings
7
+ Author-email: rjennings055@gmail.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Description-Content-Type: text/x-rst
14
+
15
+ Project Haystack
16
+ ----------------
17
+ To be defined
18
+
19
+ Installation
20
+ ------------
21
+
22
+ Download the project from Pypi:
23
+
24
+ .. code-block:: bash
25
+
26
+ $ pip install proj-haystack
27
+
28
+ Quick start
29
+ -----------
30
+
31
+ The below example shows how to obtain an auth token from a Haystack server.
32
+
33
+ .. code-block:: python
34
+
35
+ from proj_haystack.scram import get_auth_token
36
+
37
+ # define these settings specific for your use case
38
+ host_url = "http://localhost:8080"
39
+ username = "su"
40
+ password = "su"
41
+
42
+ # get the auth token
43
+ auth_token = get_auth_token(host_url, username, password)
44
+ print(f"Here is the auth token: {auth_token}")
45
+
46
+ License
47
+ -------
48
+ To be defined
@@ -0,0 +1,34 @@
1
+ Project Haystack
2
+ ----------------
3
+ To be defined
4
+
5
+ Installation
6
+ ------------
7
+
8
+ Download the project from Pypi:
9
+
10
+ .. code-block:: bash
11
+
12
+ $ pip install proj-haystack
13
+
14
+ Quick start
15
+ -----------
16
+
17
+ The below example shows how to obtain an auth token from a Haystack server.
18
+
19
+ .. code-block:: python
20
+
21
+ from proj_haystack.scram import get_auth_token
22
+
23
+ # define these settings specific for your use case
24
+ host_url = "http://localhost:8080"
25
+ username = "su"
26
+ password = "su"
27
+
28
+ # get the auth token
29
+ auth_token = get_auth_token(host_url, username, password)
30
+ print(f"Here is the auth token: {auth_token}")
31
+
32
+ License
33
+ -------
34
+ To be defined
File without changes
@@ -0,0 +1,354 @@
1
+ import hashlib
2
+ import hmac
3
+ import logging
4
+ import re
5
+ from base64 import urlsafe_b64decode, urlsafe_b64encode
6
+ from binascii import hexlify, unhexlify
7
+ from dataclasses import dataclass
8
+ from hashlib import pbkdf2_hmac
9
+ from random import getrandbits
10
+ from time import time_ns
11
+ from typing import Union
12
+
13
+ from proj_haystack.utils import request
14
+
15
+ from .utils import Response
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ gs2_header = "n,,"
20
+
21
+ # -----------------------------------------------------------------------
22
+ #
23
+ # -----------------------------------------------------------------------
24
+
25
+
26
+ def get_auth_token(host_url: str, username: str, password: str):
27
+
28
+ sc = ScramClient(username, password)
29
+
30
+ # send hello msg & set the response
31
+ hello_resp = request(host_url, headers=sc.get_hello_req())
32
+ sc.set_hello_resp(hello_resp)
33
+
34
+ # send first msg & set the response
35
+ first_resp = request(host_url, headers=sc.get_first_req())
36
+ sc.set_first_resp(first_resp)
37
+
38
+ # send last msg & set the response
39
+ last_resp = request(host_url, headers=sc.get_last_req())
40
+ sc.set_last_resp(last_resp)
41
+
42
+ # return the auth token
43
+ return sc.auth_token
44
+
45
+
46
+ # -----------------------------------------------------------------------
47
+ #
48
+ # -----------------------------------------------------------------------
49
+
50
+
51
+ class ScramException(Exception):
52
+ def __init__(self, message: str, server_error: str = None):
53
+ super().__init__(message)
54
+ self.server_error = server_error
55
+
56
+ def __str__(self):
57
+ s_str = "" if self.server_error is None else f": {self.server_error}"
58
+ return super().__str__() + s_str
59
+
60
+
61
+ # # -----------------------------------------------------------------------
62
+ # # Parsing support - general
63
+ # # -----------------------------------------------------------------------
64
+
65
+
66
+ @dataclass
67
+ class NotFoundError(Exception):
68
+ help_msg: str
69
+
70
+
71
+ class ScramClient:
72
+ def __init__(self, username: str, password: str, hash: str = "sha256"):
73
+ if hash not in ["sha100?", "sha256"]:
74
+ raise ScramException(
75
+ "The 'hash' parameter must be a str equal to 'sha256' or 'sha100?'"
76
+ )
77
+
78
+ self.username = username
79
+ self.password = password
80
+ self.hash = hash # does the server or the client determine the hash?
81
+
82
+ def get_hello_req(self) -> dict[str, str]:
83
+ """Return the HTTP headers required for the client's hello message. Note: There is no data required for the client's hello message."""
84
+ headers = {"Authorization": f"HELLO username={to_base64(self.username)}"}
85
+ return headers
86
+
87
+ def set_hello_resp(self, resp: Response) -> None:
88
+ """Save server's response data as class attributes to be able to get other request messages."""
89
+ auth_msg = fetch_auth_msg(resp.headers.as_string())
90
+ self.handshake_token = fetch_handshake_token(auth_msg)
91
+ self.hash = fetch_hash(
92
+ auth_msg
93
+ ) # Should I raise an exception if these don't line up?
94
+
95
+ def get_first_req(self) -> dict[str, str]:
96
+ self.c_nonce: str = gen_nonce()
97
+ self.c1_bare: str = f"n={self.username},r={self.c_nonce}"
98
+ headers = {
99
+ "Authorization": f"scram handshakeToken={self.handshake_token}, hash={self.hash}, data={to_base64(gs2_header+self.c1_bare)}"
100
+ }
101
+ return headers
102
+
103
+ def set_first_resp(self, resp: Response) -> None:
104
+ # can we go straight to _fetch_scram_data?
105
+ auth_msg = fetch_auth_msg(resp.headers.as_string())
106
+ scram_data = fetch_scram_data(auth_msg)
107
+ r, s, i = split_scram_data(scram_data)
108
+ self.s_nonce: str = r
109
+ self.salt: str = s
110
+ self.iter_count: int = i
111
+
112
+ def get_last_req(self):
113
+ # define the client final no proof
114
+ # note: We want to send the s_nonce
115
+ client_final_no_proof = f"c={to_base64('n,,')},r={self.s_nonce}"
116
+ logger.debug(f"client-final-no-proof:\n{client_final_no_proof}\n")
117
+
118
+ # define the auth msg
119
+ auth_msg = f"{self.c1_bare},r={self.s_nonce},s={self.salt},i={self.iter_count},{client_final_no_proof}"
120
+ logger.debug(f"auth-msg:\n{auth_msg}\n")
121
+
122
+ # define the client key
123
+ client_key = hmac.new(
124
+ unhexlify(
125
+ salted_password(
126
+ self.salt,
127
+ self.iter_count,
128
+ self.hash,
129
+ self.password,
130
+ )
131
+ ),
132
+ "Client Key".encode("UTF-8"),
133
+ self.hash,
134
+ ).hexdigest()
135
+ logger.debug(f"client-key:\n{client_key}\n")
136
+
137
+ # find the stored key
138
+ hashFunc = hashlib.new(self.hash)
139
+ hashFunc.update(unhexlify(client_key))
140
+ stored_key = hashFunc.hexdigest()
141
+ logger.debug(f"stored-key:\n{stored_key}\n")
142
+
143
+ # find the client signature
144
+ client_signature = hmac.new(
145
+ unhexlify(stored_key), auth_msg.encode("utf-8"), self.hash
146
+ ).hexdigest()
147
+ logger.debug(f"client-signature:\n{client_signature}\n")
148
+
149
+ # find the client proof
150
+ client_proof = hex(int(client_key, 16) ^ int(client_signature, 16))[2:]
151
+ logger.debug(f"Here is the length of the client proof: {len(client_proof)}")
152
+
153
+ # may need to do some padding before converting the hex to its
154
+ # binary representation
155
+ while len(client_proof) < 64:
156
+ client_proof = "0" + client_proof
157
+
158
+ client_proof_encode = to_base64(unhexlify(client_proof))
159
+ logger.debug(f"client-proof:\n{client_proof}\n")
160
+
161
+ client_final = client_final_no_proof + ",p=" + client_proof_encode
162
+ client_final_base64 = to_base64(client_final)
163
+
164
+ final_msg = (
165
+ f"scram handshaketoken={self.handshake_token},data={client_final_base64}"
166
+ )
167
+ logger.debug(f"Here is the final msg being sent: {final_msg}")
168
+
169
+ headers = {"Authorization": final_msg}
170
+ return headers
171
+
172
+ def set_last_resp(self, resp: Response) -> None:
173
+ self.auth_token = fetch_auth_token(resp.headers.as_string())
174
+
175
+
176
+ # -
177
+ # define helper funcs used in ScramClient
178
+ # -
179
+
180
+
181
+ def fetch_auth_msg(resp_msg: str) -> str:
182
+ exclude_msg = "WWW-Authenticate: "
183
+ s = re.search(f"({exclude_msg})[^\n]+", resp_msg)
184
+
185
+ # TODO: add some handling here
186
+ return s.group(0)[len(exclude_msg) :]
187
+
188
+
189
+ def fetch_scram_data(auth_msg: str) -> str:
190
+ exclude_msg = "scram data="
191
+ s = re.search(f"({exclude_msg})[a-zA-Z0-9]+", auth_msg) # TODO confirm this
192
+
193
+ if s is None:
194
+ raise NotFoundError(f"Acceptable handshake token not found:\n{auth_msg}")
195
+
196
+ return from_base64(s.group(0)[len(exclude_msg) :])
197
+
198
+
199
+ def split_scram_data(scram_data: str) -> tuple[str, str, int]:
200
+
201
+ # define s_nonce - ASCII characters excluding ','
202
+ r = re.search("(r=)[^,]+", scram_data)
203
+
204
+ # define salt - base 64 encoded str
205
+ s = re.search("(s=)[a-zA-Z0-9+/=]+", scram_data)
206
+
207
+ # define iteration count
208
+ i = re.search("(i=)[0-9]+", scram_data)
209
+
210
+ if r == None or s == None or i == None:
211
+ raise NotFoundError(f"Invalid scram data:\n{scram_data}")
212
+
213
+ s_nonce: str = r.group(0).replace("r=", "")
214
+ salt: str = s.group(0).replace("s=", "")
215
+ iteration_count: int = int(i.group(0).replace("i=", ""))
216
+
217
+ return (s_nonce, salt, iteration_count)
218
+
219
+
220
+ # def _fetch_date_time(date_str: str) -> datetime:
221
+ # format = "%a, %d %b %Y %H:%M:%S %Z"
222
+ # return datetime.strptime(date_str, format)
223
+
224
+
225
+ def fetch_handshake_token(auth_msg: str) -> str:
226
+ exclude_msg = "handshakeToken="
227
+ s = re.search(f"({exclude_msg})[a-zA-Z0-9]+", auth_msg)
228
+
229
+ if s is None:
230
+ raise NotFoundError(f"Acceptable handshake token not found:\n{auth_msg}")
231
+
232
+ return s.group(0)[len(exclude_msg) :]
233
+
234
+
235
+ def fetch_hash(auth_msg: str) -> str:
236
+ exclude_msg = "hash="
237
+ s = re.search(f"({exclude_msg})(SHA-256)", auth_msg)
238
+
239
+ if s is None:
240
+ raise NotFoundError(f"Acceptable hash method not found:\n{auth_msg}")
241
+
242
+ s_new = s.group(0)[len(exclude_msg) :]
243
+
244
+ if s_new == "SHA-256":
245
+ s_new = "sha256"
246
+
247
+ return s_new
248
+
249
+
250
+ def fetch_auth_token(headers: str) -> str:
251
+ exclude_msg = "authToken="
252
+ s = re.search(f"({exclude_msg})[^,]+", headers)
253
+
254
+ if s is None:
255
+ raise NotFoundError(f"Acceptable auth token not found:\n{headers}")
256
+
257
+ return s.group(0)[len(exclude_msg) :]
258
+
259
+
260
+ # --------------------------------------------------------------------
261
+ # Nonce & related helper funcs
262
+ # --------------------------------------------------------------------
263
+
264
+
265
+ def to_custom_hex(x: int, length: int) -> str:
266
+ """Convert an integer x to hexadecimal string representation without a prepended '0x' str. Prepend leading zeros as needed to ensure the specified number of nibble characters."""
267
+
268
+ # Convert x to a hexadecimal number
269
+ x_hex = hex(x)
270
+
271
+ # Remove prepended 0x used to describe hex numbers
272
+ x_hex = x_hex.replace("0x", "")
273
+
274
+ # Prepend 0s as needed
275
+ if len(x_hex) < length:
276
+ x_hex = "0" * (length - len(x_hex)) + x_hex
277
+
278
+ return x_hex
279
+
280
+
281
+ # Define nonce random mask for this VM
282
+ nonce_mask: int = getrandbits(64)
283
+
284
+
285
+ def gen_nonce() -> str:
286
+ """Generate a nonce."""
287
+ # Notes:
288
+ # getrandbits() defines a random 64 bit integer
289
+ # time_ns() defines ticks since the Unix epoch (1 January 1970)
290
+ rand = getrandbits(64)
291
+ ticks = time_ns() ^ nonce_mask ^ rand
292
+ return to_custom_hex(rand, 16) + to_custom_hex(ticks, 16)
293
+
294
+
295
+ # --------------------------------------------------------------------
296
+ # Misc
297
+ # --------------------------------------------------------------------
298
+
299
+
300
+ def salted_password(salt: str, iterations: int, hash_func: str, password: str) -> bytes:
301
+ # Need hash_func to be a str here
302
+ dk = pbkdf2_hmac(hash_func, password.encode(), urlsafe_b64decode(salt), iterations)
303
+ encrypt_password = hexlify(dk)
304
+ return encrypt_password
305
+
306
+
307
+ # --------------------------------------------------------------------
308
+ # Base64uri conversions & related helper funcs
309
+ # --------------------------------------------------------------------
310
+
311
+
312
+ def to_base64(msg: Union[str, bytes]) -> str:
313
+ """Encode a str or byte in base64uri format as defined by RFC 4648."""
314
+
315
+ # Convert str inputs to bytes
316
+ if isinstance(msg, str):
317
+ msg = msg.encode("utf-8")
318
+
319
+ # Encode using URL and filesystem-safe alphabet.
320
+ # This means + is encoded as -, and / is encoded as _.
321
+ output = urlsafe_b64encode(msg)
322
+
323
+ # Decode the output as a str
324
+ output = output.decode("utf-8")
325
+
326
+ # Remove padding
327
+ output = output.replace("=", "")
328
+
329
+ return output
330
+
331
+
332
+ def from_base64(msg: Union[str, bytes]) -> str:
333
+ """Decode a base64uri encoded str or bytes defined by RFC 4648 into its binary contents. Decode a URI-safe RFC 4648 encoding."""
334
+
335
+ # Convert str inputs to bytes
336
+ if isinstance(msg, str):
337
+ msg = to_padded_bytes(msg)
338
+
339
+ # Decode base64uri
340
+ msg = urlsafe_b64decode(msg)
341
+
342
+ # Decode bytes obj as a str
343
+ msg = msg.decode("utf-8")
344
+
345
+ return msg
346
+
347
+
348
+ def to_padded_bytes(s: str) -> bytes:
349
+ """If needed, apply padding to make var s a multiple of 4. Then convert to bytes obj."""
350
+ r = len(s) % 4
351
+ if r != 0:
352
+ s += "=" * (4 - r)
353
+
354
+ return s.encode("utf-8")
@@ -0,0 +1,34 @@
1
+ from proj_haystack.scram import ScramClient
2
+ from proj_haystack.utils import request
3
+
4
+
5
+ class HaystackSession:
6
+ """
7
+ Models the network session to a Project Haystack compliant web
8
+ server from a client's perspective.
9
+ """
10
+
11
+ def __init__(self, host_url: str, project: str, username: str, password: str):
12
+ self.host_url = host_url
13
+ self.project = project
14
+ self.username = username
15
+ self.password = password
16
+
17
+ # create a scram object
18
+ scram = ScramScheme(host_url, username, password)
19
+
20
+ # try to get the auth token - TODO add a try/catch block here
21
+ self.auth_token = scram.get_auth_token()
22
+
23
+ def exec_query(self, query: str, format: str):
24
+ """
25
+ An acceptable format is "text/csv"
26
+ """
27
+ url = f"{self.host_url}/api/{self.project}/{query}"
28
+ header = {
29
+ "Authorization": f"BEARER authToken={self.auth_token}",
30
+ "Accept": format,
31
+ }
32
+ response = requests.get(url, headers=header)
33
+
34
+ return response.text
@@ -0,0 +1,102 @@
1
+ import json
2
+ import ssl
3
+ import typing
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from email.message import Message
8
+
9
+
10
+ class Response(typing.NamedTuple):
11
+ """Container for HTTP response."""
12
+
13
+ body: str
14
+ headers: Message
15
+ status: int
16
+ error_count: int = 0
17
+
18
+ def json(self) -> typing.Any:
19
+ """
20
+ Decode body's JSON.
21
+ Returns:
22
+ Pythonic representation of the JSON object
23
+ """
24
+ try:
25
+ output = json.loads(self.body)
26
+ except json.JSONDecodeError:
27
+ output = ""
28
+ return output
29
+
30
+
31
+ def request(
32
+ url: str,
33
+ data: dict = None,
34
+ params: dict = None,
35
+ headers: dict = None,
36
+ method: str = "GET",
37
+ data_as_json: bool = True,
38
+ error_count: int = 0,
39
+ ) -> Response:
40
+ """
41
+ Perform HTTP request.
42
+ Args:
43
+ url: url to fetch
44
+ data: dict of keys/values to be encoded and submitted
45
+ params: dict of keys/values to be encoded in URL query string
46
+ headers: optional dict of request headers
47
+ method: HTTP method , such as GET or POST
48
+ data_as_json: if True, data will be JSON-encoded
49
+ error_count: optional current count of HTTP errors, to manage recursion
50
+ Raises:
51
+ URLError: if url starts with anything other than "http"
52
+ Returns:
53
+ A dict with headers, body, status code, and, if applicable, object
54
+ rendered from JSON
55
+ """
56
+ if not url.startswith("http"):
57
+ raise urllib.error.URLError("Incorrect and possibly insecure protocol in url")
58
+ method = method.upper()
59
+ request_data = None
60
+ headers = headers or {}
61
+ data = data or {}
62
+ params = params or {}
63
+ headers = {"Accept": "application/json", **headers}
64
+
65
+ if method == "GET":
66
+ params = {**params, **data}
67
+ data = None
68
+
69
+ if params:
70
+ url += "?" + urllib.parse.urlencode(params, doseq=True, safe="/")
71
+
72
+ if data:
73
+ if data_as_json:
74
+ request_data = json.dumps(data).encode()
75
+ headers["Content-Type"] = "application/json; charset=UTF-8"
76
+ else:
77
+ request_data = urllib.parse.urlencode(data).encode()
78
+
79
+ httprequest = urllib.request.Request(
80
+ url, data=request_data, headers=headers, method=method
81
+ )
82
+
83
+ try:
84
+ with urllib.request.urlopen(
85
+ httprequest, context=ssl.create_default_context()
86
+ ) as httpresponse:
87
+ response = Response(
88
+ headers=httpresponse.headers,
89
+ status=httpresponse.status,
90
+ body=httpresponse.read().decode(
91
+ httpresponse.headers.get_content_charset("utf-8")
92
+ ),
93
+ )
94
+ except urllib.error.HTTPError as e:
95
+ response = Response(
96
+ body=str(e.reason),
97
+ headers=e.headers,
98
+ status=e.code,
99
+ error_count=error_count + 1,
100
+ )
101
+
102
+ return response
@@ -0,0 +1,21 @@
1
+ [tool.poetry]
2
+ name = "phable"
3
+ version = "0.1.4"
4
+ description = ""
5
+ authors = ["Rick Jennings <rjennings055@gmail.com>"]
6
+ readme = "README.rst"
7
+ packages = [{include = "phable"}]
8
+ license = "MIT"
9
+
10
+ [tool.poetry.dependencies]
11
+ python = "^3.10"
12
+
13
+
14
+ [tool.poetry.group.dev.dependencies]
15
+ black = "^23.1.0"
16
+ flake8 = "^6.0.0"
17
+ mypy = "^1.1.1"
18
+
19
+ [build-system]
20
+ requires = ["poetry-core"]
21
+ build-backend = "poetry.core.masonry.api"