otdf-python 0.6.0__py3-none-any.whl → 0.7.3__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.
- otdf_python/auth_headers.py +1 -0
- otdf_python/kas_client.py +4 -0
- otdf_python/kas_connect_rpc_client.py +29 -28
- otdf_python/kas_info.py +1 -0
- otdf_python/key_type_constants.py +1 -0
- otdf_python/nanotdf_ecdsa_struct.py +1 -0
- otdf_python/policy_binding_serializer.py +1 -0
- otdf_python/sdk.py +2 -0
- otdf_python/sdk_builder.py +1 -1
- otdf_python/tdf_reader.py +1 -0
- otdf_python/token_source.py +1 -1
- {otdf_python-0.6.0.dist-info → otdf_python-0.7.3.dist-info}/METADATA +4 -8
- {otdf_python-0.6.0.dist-info → otdf_python-0.7.3.dist-info}/RECORD +14 -15
- {otdf_python-0.6.0.dist-info → otdf_python-0.7.3.dist-info}/WHEEL +1 -1
- otdf_python-0.6.0.dist-info/licenses/LICENSE +0 -21
otdf_python/auth_headers.py
CHANGED
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
@dataclass
|
|
7
7
|
class AuthHeaders:
|
|
8
8
|
"""Represents authentication headers used in token-based authorization.
|
|
9
|
+
|
|
9
10
|
This class holds authorization and DPoP (Demonstrating Proof of Possession) headers
|
|
10
11
|
that are used in token-based API requests.
|
|
11
12
|
"""
|
otdf_python/kas_client.py
CHANGED
|
@@ -362,6 +362,7 @@ class KASClient:
|
|
|
362
362
|
self, policy_json, client_public_key, key_access, session_key_type=None
|
|
363
363
|
):
|
|
364
364
|
"""Create a signed JWT for the rewrap request.
|
|
365
|
+
|
|
365
366
|
The JWT is signed with the DPoP private key.
|
|
366
367
|
|
|
367
368
|
Args:
|
|
@@ -400,6 +401,7 @@ class KASClient:
|
|
|
400
401
|
|
|
401
402
|
def _create_connect_rpc_signed_token(self, key_access, policy_json):
|
|
402
403
|
"""Create a signed token specifically for Connect RPC requests.
|
|
404
|
+
|
|
403
405
|
For now, this delegates to the existing JWT creation method.
|
|
404
406
|
"""
|
|
405
407
|
return self._create_signed_request_jwt(
|
|
@@ -469,6 +471,7 @@ class KASClient:
|
|
|
469
471
|
|
|
470
472
|
def get_public_key(self, kas_info):
|
|
471
473
|
"""Get KAS public key using Connect RPC.
|
|
474
|
+
|
|
472
475
|
Checks cache first if available.
|
|
473
476
|
"""
|
|
474
477
|
try:
|
|
@@ -569,6 +572,7 @@ class KASClient:
|
|
|
569
572
|
|
|
570
573
|
def _prepare_rsa_keypair(self):
|
|
571
574
|
"""Prepare RSA key pair for unwrapping, reusing if possible.
|
|
575
|
+
|
|
572
576
|
Uses separate ephemeral keys for encryption (not DPoP keys).
|
|
573
577
|
|
|
574
578
|
Returns:
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""KASConnectRPCClient: Handles Connect RPC communication with the Key Access Service (KAS).
|
|
2
|
+
|
|
2
3
|
This class encapsulates all interactions with otdf_python_proto.
|
|
3
4
|
"""
|
|
4
5
|
|
|
5
6
|
import logging
|
|
6
7
|
|
|
7
|
-
import
|
|
8
|
+
import pyqwest
|
|
8
9
|
from otdf_python_proto.kas import kas_pb2
|
|
9
10
|
from otdf_python_proto.kas.kas_connect import AccessServiceClientSync
|
|
10
11
|
|
|
@@ -26,6 +27,7 @@ class KASConnectRPCClient:
|
|
|
26
27
|
"""
|
|
27
28
|
self.use_plaintext = use_plaintext
|
|
28
29
|
self.verify_ssl = verify_ssl
|
|
30
|
+
self._transport = None
|
|
29
31
|
self._http_client = None
|
|
30
32
|
|
|
31
33
|
def __enter__(self):
|
|
@@ -38,35 +40,41 @@ class KASConnectRPCClient:
|
|
|
38
40
|
self.close()
|
|
39
41
|
|
|
40
42
|
def close(self):
|
|
41
|
-
"""Close HTTP client and release resources.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"""
|
|
47
|
-
if self._http_client is not None:
|
|
48
|
-
self._http_client.close()
|
|
49
|
-
self._http_client = None
|
|
43
|
+
"""Close HTTP client and release resources."""
|
|
44
|
+
if self._transport is not None:
|
|
45
|
+
self._transport.close()
|
|
46
|
+
self._transport = None
|
|
47
|
+
self._http_client = None
|
|
50
48
|
|
|
51
49
|
def _create_http_client(self):
|
|
52
|
-
"""Create HTTP client
|
|
50
|
+
"""Create HTTP client backed by a pyqwest.SyncHTTPTransport.
|
|
51
|
+
|
|
52
|
+
Supports plaintext (HTTP) and TLS (HTTPS) connections.
|
|
53
|
+
Custom CA/cert data is not yet supported.
|
|
53
54
|
|
|
54
55
|
Returns:
|
|
55
|
-
|
|
56
|
+
pyqwest.SyncClient configured for the current settings
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
NotImplementedError: If verify_ssl=False is requested, as pyqwest
|
|
60
|
+
does not support disabling TLS verification. Passing custom
|
|
61
|
+
TLS certificate or CA data is also not yet implemented.
|
|
56
62
|
|
|
57
63
|
"""
|
|
58
|
-
if self.verify_ssl:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
if not self.verify_ssl and not self.use_plaintext:
|
|
65
|
+
raise NotImplementedError(
|
|
66
|
+
"verify_ssl=False is not supported for HTTPS connections: pyqwest "
|
|
67
|
+
"does not expose an option to disable TLS verification. Passing "
|
|
68
|
+
"custom TLS cert or CA data is also not yet implemented."
|
|
69
|
+
)
|
|
70
|
+
self._transport = pyqwest.SyncHTTPTransport()
|
|
71
|
+
return pyqwest.SyncClient(transport=self._transport)
|
|
64
72
|
|
|
65
73
|
def _get_http_client(self):
|
|
66
74
|
"""Get HTTP client, creating one if needed for backward compatibility.
|
|
67
75
|
|
|
68
76
|
Returns:
|
|
69
|
-
|
|
77
|
+
pyqwest.SyncClient instance
|
|
70
78
|
|
|
71
79
|
"""
|
|
72
80
|
if self._http_client is None:
|
|
@@ -136,12 +144,9 @@ class KASConnectRPCClient:
|
|
|
136
144
|
f"for public key retrieval"
|
|
137
145
|
)
|
|
138
146
|
|
|
139
|
-
# Get or create HTTP client
|
|
140
147
|
http_client = self._get_http_client()
|
|
141
|
-
|
|
142
|
-
# Create Connect RPC client with configured HTTP client
|
|
143
148
|
client = AccessServiceClientSync(
|
|
144
|
-
address=connect_rpc_base_url,
|
|
149
|
+
address=connect_rpc_base_url, http_client=http_client
|
|
145
150
|
)
|
|
146
151
|
|
|
147
152
|
# Create public key request
|
|
@@ -202,12 +207,9 @@ class KASConnectRPCClient:
|
|
|
202
207
|
f"Creating Connect RPC client for base URL: {kas_service_url}, for unwrap"
|
|
203
208
|
)
|
|
204
209
|
|
|
205
|
-
# Get or create HTTP client
|
|
206
210
|
http_client = self._get_http_client()
|
|
207
|
-
|
|
208
|
-
# Create Connect RPC client with configured HTTP client
|
|
209
211
|
client = AccessServiceClientSync(
|
|
210
|
-
address=kas_service_url,
|
|
212
|
+
address=kas_service_url, http_client=http_client
|
|
211
213
|
)
|
|
212
214
|
|
|
213
215
|
# Create rewrap request
|
|
@@ -215,7 +217,6 @@ class KASConnectRPCClient:
|
|
|
215
217
|
signed_request_token=signed_token,
|
|
216
218
|
)
|
|
217
219
|
|
|
218
|
-
# Debug: Log the signed token details
|
|
219
220
|
logging.info(f"Connect RPC signed token: {signed_token}")
|
|
220
221
|
|
|
221
222
|
# Prepare headers with authentication if available
|
otdf_python/kas_info.py
CHANGED
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
@dataclass
|
|
7
7
|
class KASInfo:
|
|
8
8
|
"""Configuration for Key Access Server (KAS) information.
|
|
9
|
+
|
|
9
10
|
This class stores details about a Key Access Server including its URL,
|
|
10
11
|
public key, key ID, default status, and cryptographic algorithm.
|
|
11
12
|
"""
|
otdf_python/sdk.py
CHANGED
|
@@ -15,6 +15,7 @@ class KAS(AbstractContextManager):
|
|
|
15
15
|
|
|
16
16
|
def get_public_key(self, kas_info: Any) -> Any:
|
|
17
17
|
"""Retrieve the public key from KAS for RSA operations.
|
|
18
|
+
|
|
18
19
|
If the public key is cached, returns the cached value.
|
|
19
20
|
Otherwise, makes a request to the KAS.
|
|
20
21
|
|
|
@@ -217,6 +218,7 @@ class SDK(AbstractContextManager):
|
|
|
217
218
|
|
|
218
219
|
def kas(self) -> KAS:
|
|
219
220
|
"""Return the KAS client for key access operations.
|
|
221
|
+
|
|
220
222
|
This should be implemented to return an instance of KAS.
|
|
221
223
|
"""
|
|
222
224
|
raise NotImplementedError
|
otdf_python/sdk_builder.py
CHANGED
otdf_python/tdf_reader.py
CHANGED
|
@@ -12,6 +12,7 @@ TDF_PAYLOAD_FILE_NAME = "0.payload"
|
|
|
12
12
|
|
|
13
13
|
class TDFReader:
|
|
14
14
|
"""TDFReader is responsible for reading and processing Trusted Data Format (TDF) files.
|
|
15
|
+
|
|
15
16
|
The class initializes with a TDF file channel, extracts the manifest and payload entries,
|
|
16
17
|
and provides methods to retrieve the manifest content, read payload bytes, and read policy objects.
|
|
17
18
|
"""
|
otdf_python/token_source.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: otdf-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: Unofficial OpenTDF SDK for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/b-long/opentdf-python-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/b-long/opentdf-python-sdk
|
|
7
7
|
Project-URL: Issues, https://github.com/b-long/opentdf-python-sdk/issues
|
|
8
8
|
Author-email: b-long <b-long@users.noreply.github.com>
|
|
9
|
-
License-File: LICENSE
|
|
10
9
|
Classifier: Development Status :: 4 - Beta
|
|
11
10
|
Classifier: Intended Audience :: Developers
|
|
12
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -22,15 +21,12 @@ Classifier: Topic :: Security :: Cryptography
|
|
|
22
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
22
|
Classifier: Typing :: Typed
|
|
24
23
|
Requires-Python: >=3.10
|
|
25
|
-
Requires-Dist: connect-python
|
|
24
|
+
Requires-Dist: connect-python>=0.9.0
|
|
26
25
|
Requires-Dist: cryptography>=45.0.4
|
|
27
26
|
Requires-Dist: grpcio-status>=1.74.0
|
|
28
|
-
Requires-Dist: grpcio-tools>=1.74.0
|
|
29
27
|
Requires-Dist: grpcio>=1.74.0
|
|
30
|
-
Requires-Dist:
|
|
28
|
+
Requires-Dist: httpx2>=2.3.0
|
|
31
29
|
Requires-Dist: protobuf>=6.31.1
|
|
32
|
-
Requires-Dist: protoc-gen-connect-python<0.7,>=0.6.0
|
|
33
|
-
Requires-Dist: protoc-gen-openapiv2>=0.0.1
|
|
34
30
|
Requires-Dist: pyjwt>=2.10.1
|
|
35
31
|
Requires-Dist: typing-extensions>=4.14.1
|
|
36
32
|
Description-Content-Type: text/markdown
|
|
@@ -137,7 +133,7 @@ with open("decrypted.txt", "wb") as f:
|
|
|
137
133
|
## Project Structure
|
|
138
134
|
|
|
139
135
|
```
|
|
140
|
-
src/otdf_python/
|
|
136
|
+
packages/otdf-python/src/otdf_python/
|
|
141
137
|
├── sdk.py # Main SDK interface
|
|
142
138
|
├── config.py # Configuration management
|
|
143
139
|
├── tdf.py # TDF format handling
|
|
@@ -4,7 +4,7 @@ otdf_python/address_normalizer.py,sha256=7gigfxFIFnDILi8BGQipuLl-Cr71pwi5vE72hbp
|
|
|
4
4
|
otdf_python/aesgcm.py,sha256=jQgVSEeKM6MJuH2tvlClXIzvg11a5O_GEY9Sh56CrxE,1947
|
|
5
5
|
otdf_python/assertion_config.py,sha256=rw0SIB3xG-nAeb5r_liuxLphU4tcj-zlq8rVvXncX-Y,2317
|
|
6
6
|
otdf_python/asym_crypto.py,sha256=EYkMNhZJP5khH0IvICTOG2bMg_TMvd6wXDu5zW0jpj4,7234
|
|
7
|
-
otdf_python/auth_headers.py,sha256=
|
|
7
|
+
otdf_python/auth_headers.py,sha256=onS-fXsIZN4l33yQS9VspPvoiAnfM2B0nNAVL_YIGZ0,987
|
|
8
8
|
otdf_python/autoconfigure_utils.py,sha256=W5aJ0tC7HWfGb_1Mva_oxgduUzSpPoyDeaq0rPwgPAs,3689
|
|
9
9
|
otdf_python/cli.py,sha256=icooiGgRh8H1IlP-_iO7paLB60IXUSaSacODABeqrS4,21165
|
|
10
10
|
otdf_python/collection_store.py,sha256=sYL6VMFDBfHfCCLk14iybeC_qoUlpJFB0wOMt1bdwpY,1429
|
|
@@ -21,29 +21,29 @@ otdf_python/eckeypair.py,sha256=qcPKv0OS1lYxRICj9dhAW_eMz32anFBtpI8EJfXxpX0,2470
|
|
|
21
21
|
otdf_python/header.py,sha256=peG14kE_KAUCW4fY82sqcWF5zTAVAnJfFXCHtC8Z0iQ,7189
|
|
22
22
|
otdf_python/invalid_zip_exception.py,sha256=M_bAiXEjJdxPfA178YH-uHGRwMrNBKzjQzlQ54aDP2w,292
|
|
23
23
|
otdf_python/kas_allowlist.py,sha256=0yX3J9J1od_ew0sp3pX6kfav39pTz7aBBpMnVkE5T7s,5884
|
|
24
|
-
otdf_python/kas_client.py,sha256=
|
|
25
|
-
otdf_python/kas_connect_rpc_client.py,sha256=
|
|
26
|
-
otdf_python/kas_info.py,sha256=
|
|
24
|
+
otdf_python/kas_client.py,sha256=kcLpdkFDN3LVmlmSuaEm6EKtPsbOK5RCsClPdm8bpAU,27536
|
|
25
|
+
otdf_python/kas_connect_rpc_client.py,sha256=Y2w0SPh4QZxLLhneKky8N20jd9UQmShmAnLo_c9kOXk,9113
|
|
26
|
+
otdf_python/kas_info.py,sha256=R_zfW8m2n2PT1-Enk6NG5K8YIjWvb1g-PQ6M0bZX-o8,739
|
|
27
27
|
otdf_python/kas_key_cache.py,sha256=6hfzRAg9o_IfRErWSe-_gGTG9kRyYENMizMY1Shkmfk,1548
|
|
28
28
|
otdf_python/key_type.py,sha256=2gQlXOj35J3ISCcWjU3sGYUxmlZR47BMq6Xr2yoKA8k,928
|
|
29
|
-
otdf_python/key_type_constants.py,sha256=
|
|
29
|
+
otdf_python/key_type_constants.py,sha256=0aqG1N1Ay0Z8o5fsq7fAiJXQeD6k-fxp06If__BGcR4,955
|
|
30
30
|
otdf_python/manifest.py,sha256=aglGw9EdtZZIxmwqy82sV5wum_mKkjzew4brSgxmJjc,7047
|
|
31
31
|
otdf_python/nanotdf.py,sha256=y1mLktlZ-mRCup2vzGY4ZbFeNEhWpCYLOZYjzTSPEtc,33921
|
|
32
|
-
otdf_python/nanotdf_ecdsa_struct.py,sha256=
|
|
32
|
+
otdf_python/nanotdf_ecdsa_struct.py,sha256=C-qQnroJGO9yXrBb-p-rYMSxf2gS8kF6SCUvO-njAXU,4067
|
|
33
33
|
otdf_python/nanotdf_type.py,sha256=3MQzT6lJ3WJKMICFyyYZXX2_cFYcZ5G4m1uif-l9Nxo,1112
|
|
34
|
-
otdf_python/policy_binding_serializer.py,sha256=
|
|
34
|
+
otdf_python/policy_binding_serializer.py,sha256=hA-M5BhEa3Q0bpjbpdiaJ6mivwx9UGDdmB8HvFG7SY0,1272
|
|
35
35
|
otdf_python/policy_info.py,sha256=aq74dZg9PhTZ6cMkZyFsu3D754C5YijFMiuoYEL-1bY,2076
|
|
36
36
|
otdf_python/policy_object.py,sha256=LikIsahPkKr-iYA0lhgQitCbh8CsmxUBYyBs6VYfmxY,512
|
|
37
37
|
otdf_python/policy_stub.py,sha256=RfU_fICqsAOnTXOHpKhtKC0RJ3KoWhDxO0XecZWM548,159
|
|
38
38
|
otdf_python/resource_locator.py,sha256=bjK935XcfNq-PyqidHNq8eIiPeZEStYdQvmQ9B9GY20,6290
|
|
39
|
-
otdf_python/sdk.py,sha256
|
|
40
|
-
otdf_python/sdk_builder.py,sha256=
|
|
39
|
+
otdf_python/sdk.py,sha256=6yQbaHS6EUqiEOEn2hI3Ox4OYlYQHDuxjqHjYo8LV5A,15060
|
|
40
|
+
otdf_python/sdk_builder.py,sha256=N57HrXADKLSP3bGiltYV058DxgrJyMzhsKuqNuKBqqo,17659
|
|
41
41
|
otdf_python/sdk_exceptions.py,sha256=2GElGyM5LKN8Uh_lAiT6Ho4oNRWYRQsMNOK5s2jiv28,687
|
|
42
42
|
otdf_python/symmetric_and_payload_config.py,sha256=iPHeZSeY9BjsQ-wkvdm6-FIR7773EgGiaIvSG-ICOHw,1158
|
|
43
43
|
otdf_python/tdf.py,sha256=lqaSSGfd-nzlmjpYoXa6ZLihZIJA170dwodKt_iRko8,20799
|
|
44
|
-
otdf_python/tdf_reader.py,sha256=
|
|
44
|
+
otdf_python/tdf_reader.py,sha256=f-LCb_Z4TwaByT8_gu-cpJIdW8lk3741jCAd-fJOLJg,5229
|
|
45
45
|
otdf_python/tdf_writer.py,sha256=FLm1P26J4p6WPyKsjOb7QLYJqDIMDsBONqBW_JuFxyw,798
|
|
46
|
-
otdf_python/token_source.py,sha256=
|
|
46
|
+
otdf_python/token_source.py,sha256=Ow4bZqPqVxbZx64M7pLxQarVlPfiz15ZvZr9H17_ZMY,1021
|
|
47
47
|
otdf_python/version.py,sha256=uDKJKsSQoaEH-JlqAwiXDxceLRX9hkG4I3NVLEfDCHA,2025
|
|
48
48
|
otdf_python/zip_reader.py,sha256=qrHv-ecs09tz99ZKdOMiWaciYf2XsQOUTiXy1JHjuEY,1705
|
|
49
49
|
otdf_python/zip_writer.py,sha256=5-KChgEXCf4TKAAML-R8cqpAiap85ur1l2lJCCac6BE,2405
|
|
@@ -170,7 +170,6 @@ otdf_python_proto/wellknownconfiguration/__init__.py,sha256=xK8XUrwCL9elWuMTx6vV
|
|
|
170
170
|
otdf_python_proto/wellknownconfiguration/wellknown_configuration_connect.py,sha256=wnfZ7K9vAB_Rub25KrdnKIfx5Yxq8yCc8c6zHjBtFH8,6358
|
|
171
171
|
otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2.py,sha256=g9xSm9TxX0IPMqiFCaridJvI2TrL8PrXVFPgu8tX9VM,3863
|
|
172
172
|
otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2.pyi,sha256=Zw4vROvTgomnFqsalJrYda632ojXH0FVXSzTXxerybw,1490
|
|
173
|
-
otdf_python-0.
|
|
174
|
-
otdf_python-0.
|
|
175
|
-
otdf_python-0.
|
|
176
|
-
otdf_python-0.6.0.dist-info/RECORD,,
|
|
173
|
+
otdf_python-0.7.3.dist-info/METADATA,sha256=sg8YiLbazn2CqAfjLSApxd6P3UsjGEswjHr4FTcJDd8,5037
|
|
174
|
+
otdf_python-0.7.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
175
|
+
otdf_python-0.7.3.dist-info/RECORD,,
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2024 to present b-long
|
|
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.
|