otdf-python 0.10.2__py3-none-any.whl → 0.12.0__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/__init__.py +24 -0
- otdf_python/__main__.py +11 -0
- otdf_python/address_normalizer.py +82 -0
- otdf_python/aesgcm.py +70 -0
- otdf_python/assertion_config.py +105 -0
- otdf_python/asym_crypto.py +194 -0
- otdf_python/auth_headers.py +35 -0
- otdf_python/autoconfigure_utils.py +127 -0
- otdf_python/cli.py +697 -0
- otdf_python/collection_store.py +53 -0
- otdf_python/collection_store_impl.py +27 -0
- otdf_python/config.py +82 -0
- otdf_python/connect_client.py +1 -0
- otdf_python/constants.py +3 -0
- otdf_python/crypto_utils.py +82 -0
- otdf_python/dpop.py +79 -0
- otdf_python/ecc_constants.py +174 -0
- otdf_python/ecc_mode.py +88 -0
- otdf_python/ecdh.py +310 -0
- otdf_python/eckeypair.py +80 -0
- otdf_python/header.py +186 -0
- otdf_python/invalid_zip_exception.py +12 -0
- otdf_python/kas_allowlist.py +182 -0
- otdf_python/kas_client.py +752 -0
- otdf_python/kas_connect_rpc_client.py +251 -0
- otdf_python/kas_info.py +27 -0
- otdf_python/kas_key_cache.py +53 -0
- otdf_python/key_type.py +35 -0
- otdf_python/key_type_constants.py +37 -0
- otdf_python/manifest.py +256 -0
- otdf_python/nanotdf.py +863 -0
- otdf_python/nanotdf_ecdsa_struct.py +127 -0
- otdf_python/nanotdf_type.py +55 -0
- otdf_python/policy_binding_serializer.py +42 -0
- otdf_python/policy_info.py +61 -0
- otdf_python/policy_object.py +30 -0
- otdf_python/policy_stub.py +4 -0
- otdf_python/resource_locator.py +181 -0
- otdf_python/sdk.py +455 -0
- otdf_python/sdk_builder.py +557 -0
- otdf_python/sdk_exceptions.py +26 -0
- otdf_python/symmetric_and_payload_config.py +36 -0
- otdf_python/tdf.py +538 -0
- otdf_python/tdf_reader.py +208 -0
- otdf_python/tdf_writer.py +30 -0
- otdf_python/token_source.py +35 -0
- otdf_python/version.py +62 -0
- otdf_python/zip_reader.py +55 -0
- otdf_python/zip_writer.py +81 -0
- {otdf_python-0.10.2.dist-info → otdf_python-0.12.0.dist-info}/METADATA +9 -3
- {otdf_python-0.10.2.dist-info → otdf_python-0.12.0.dist-info}/RECORD +52 -3
- {otdf_python-0.10.2.dist-info → otdf_python-0.12.0.dist-info}/WHEEL +1 -1
otdf_python/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""OpenTDF Python SDK.
|
|
2
|
+
|
|
3
|
+
A Python implementation of the OpenTDF SDK for working with Trusted Data Format (TDF) files.
|
|
4
|
+
Provides both programmatic APIs and command-line interface for encryption and decryption.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .cli import main as cli_main
|
|
8
|
+
from .config import KASInfo, NanoTDFConfig, TDFConfig
|
|
9
|
+
from .sdk import SDK
|
|
10
|
+
from .sdk_builder import SDKBuilder
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"SDK",
|
|
14
|
+
"KASInfo",
|
|
15
|
+
"NanoTDFConfig",
|
|
16
|
+
"SDKBuilder",
|
|
17
|
+
"TDFConfig",
|
|
18
|
+
"cli_main",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
"""Entry point for the CLI."""
|
|
24
|
+
cli_main()
|
otdf_python/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Main entry point for running otdf_python as a module.
|
|
3
|
+
|
|
4
|
+
This allows the package to be run with `python -m otdf_python` and properly
|
|
5
|
+
handles the CLI interface without import conflicts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .cli import main
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
main()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Address normalization utilities for OpenTDF."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from .sdk_exceptions import SDKException
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_address(url_string: str, use_plaintext: bool) -> str:
|
|
13
|
+
"""Normalize a URL address to ensure it has the correct scheme and port.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
url_string: The URL string to normalize
|
|
17
|
+
use_plaintext: If True, use http scheme, otherwise use https
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The normalized URL string
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
SDKException: If there's an error parsing or creating the URL
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
scheme = "http" if use_plaintext else "https"
|
|
27
|
+
|
|
28
|
+
# Check if we have a host:port format without scheme (with non-digit port)
|
|
29
|
+
host_port_pattern = re.match(r"^([^/:]+):([^/]+)$", url_string)
|
|
30
|
+
if host_port_pattern:
|
|
31
|
+
host = host_port_pattern.group(1)
|
|
32
|
+
port_str = host_port_pattern.group(2)
|
|
33
|
+
try:
|
|
34
|
+
port = int(port_str)
|
|
35
|
+
except ValueError as err:
|
|
36
|
+
raise SDKException(f"Invalid port in URL [{url_string}]") from err
|
|
37
|
+
|
|
38
|
+
normalized_url = f"{scheme}://{host}:{port}"
|
|
39
|
+
logger.debug(f"normalized url [{url_string}] to [{normalized_url}]")
|
|
40
|
+
return normalized_url
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Check if we just have a hostname without scheme and port
|
|
44
|
+
if "://" not in url_string and "/" not in url_string and ":" not in url_string:
|
|
45
|
+
port = 80 if use_plaintext else 443
|
|
46
|
+
normalized_url = f"{scheme}://{url_string}:{port}"
|
|
47
|
+
logger.debug(f"normalized url [{url_string}] to [{normalized_url}]")
|
|
48
|
+
return normalized_url
|
|
49
|
+
|
|
50
|
+
# Parse the URL
|
|
51
|
+
parsed_url = urlparse(url_string)
|
|
52
|
+
|
|
53
|
+
# If no scheme, add one
|
|
54
|
+
if not parsed_url.scheme:
|
|
55
|
+
url_string = f"{scheme}://{url_string}"
|
|
56
|
+
parsed_url = urlparse(url_string)
|
|
57
|
+
|
|
58
|
+
# Extract host and port
|
|
59
|
+
host = parsed_url.netloc.split(":")[0] if parsed_url.netloc else parsed_url.path
|
|
60
|
+
|
|
61
|
+
# If there's a port in the URL, try to extract it
|
|
62
|
+
port = None
|
|
63
|
+
if ":" in parsed_url.netloc:
|
|
64
|
+
_, port_str = parsed_url.netloc.split(":", 1)
|
|
65
|
+
try:
|
|
66
|
+
port = int(port_str)
|
|
67
|
+
except ValueError as err:
|
|
68
|
+
raise SDKException(f"Invalid port in URL [{url_string}]") from err
|
|
69
|
+
|
|
70
|
+
# If no port was found or extracted, use the default
|
|
71
|
+
if port is None:
|
|
72
|
+
port = 80 if use_plaintext else 443
|
|
73
|
+
|
|
74
|
+
# Reconstruct the URL with the desired scheme
|
|
75
|
+
normalized_url = f"{scheme}://{host}:{port}"
|
|
76
|
+
logger.debug(f"normalized url [{url_string}] to [{normalized_url}]")
|
|
77
|
+
return normalized_url
|
|
78
|
+
|
|
79
|
+
except Exception as e:
|
|
80
|
+
if isinstance(e, SDKException):
|
|
81
|
+
raise e
|
|
82
|
+
raise SDKException(f"Error normalizing URL [{url_string}]: {e}") from e
|
otdf_python/aesgcm.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""AES-GCM encryption and decryption functionality."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AesGcm:
|
|
9
|
+
"""AES-GCM encryption and decryption operations."""
|
|
10
|
+
|
|
11
|
+
GCM_NONCE_LENGTH = 12
|
|
12
|
+
GCM_TAG_LENGTH = 16
|
|
13
|
+
|
|
14
|
+
def __init__(self, key: bytes):
|
|
15
|
+
"""Initialize AES-GCM cipher with key."""
|
|
16
|
+
if not key or len(key) not in (16, 24, 32):
|
|
17
|
+
raise ValueError("Invalid key size for GCM encryption")
|
|
18
|
+
self.key = key
|
|
19
|
+
self.aesgcm = AESGCM(key)
|
|
20
|
+
|
|
21
|
+
def get_key(self) -> bytes:
|
|
22
|
+
return self.key
|
|
23
|
+
|
|
24
|
+
class Encrypted:
|
|
25
|
+
"""Encrypted data with initialization vector and ciphertext."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, iv: bytes, ciphertext: bytes):
|
|
28
|
+
"""Initialize encrypted data."""
|
|
29
|
+
self.iv = iv
|
|
30
|
+
self.ciphertext = ciphertext
|
|
31
|
+
|
|
32
|
+
def as_bytes(self) -> bytes:
|
|
33
|
+
return self.iv + self.ciphertext
|
|
34
|
+
|
|
35
|
+
def encrypt(
|
|
36
|
+
self, plaintext: bytes, offset: int = 0, length: int | None = None
|
|
37
|
+
) -> "AesGcm.Encrypted":
|
|
38
|
+
if length is None:
|
|
39
|
+
length = len(plaintext) - offset
|
|
40
|
+
iv = os.urandom(self.GCM_NONCE_LENGTH)
|
|
41
|
+
ct = self.aesgcm.encrypt(iv, plaintext[offset : offset + length], None)
|
|
42
|
+
return self.Encrypted(iv, ct)
|
|
43
|
+
|
|
44
|
+
def encrypt_with_iv(
|
|
45
|
+
self,
|
|
46
|
+
iv: bytes,
|
|
47
|
+
# Kept for signature parity with the Java SDK; the cryptography lib
|
|
48
|
+
# derives the tag length itself.
|
|
49
|
+
auth_tag_len: int, # noqa: ARG002
|
|
50
|
+
plaintext: bytes,
|
|
51
|
+
offset: int = 0,
|
|
52
|
+
length: int | None = None,
|
|
53
|
+
) -> bytes:
|
|
54
|
+
if length is None:
|
|
55
|
+
length = len(plaintext) - offset
|
|
56
|
+
ct = self.aesgcm.encrypt(iv, plaintext[offset : offset + length], None)
|
|
57
|
+
return iv + ct
|
|
58
|
+
|
|
59
|
+
def decrypt(self, encrypted: "AesGcm.Encrypted") -> bytes:
|
|
60
|
+
return self.aesgcm.decrypt(encrypted.iv, encrypted.ciphertext, None)
|
|
61
|
+
|
|
62
|
+
def decrypt_with_iv(
|
|
63
|
+
self,
|
|
64
|
+
iv: bytes,
|
|
65
|
+
# Kept for signature parity with the Java SDK; the cryptography lib
|
|
66
|
+
# derives the tag length itself.
|
|
67
|
+
auth_tag_len: int, # noqa: ARG002
|
|
68
|
+
cipher_data: bytes,
|
|
69
|
+
) -> bytes:
|
|
70
|
+
return self.aesgcm.decrypt(iv, cipher_data, None)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Assertion configuration for TDF."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Type(Enum):
|
|
8
|
+
"""Assertion type enumeration."""
|
|
9
|
+
|
|
10
|
+
HANDLING_ASSERTION = "handling"
|
|
11
|
+
BASE_ASSERTION = "base"
|
|
12
|
+
|
|
13
|
+
def __str__(self):
|
|
14
|
+
return self.value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Scope(Enum):
|
|
18
|
+
"""Assertion scope enumeration."""
|
|
19
|
+
|
|
20
|
+
TRUSTED_DATA_OBJ = "tdo"
|
|
21
|
+
PAYLOAD = "payload"
|
|
22
|
+
|
|
23
|
+
def __str__(self):
|
|
24
|
+
return self.value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AssertionKeyAlg(Enum):
|
|
28
|
+
"""Assertion key algorithm enumeration."""
|
|
29
|
+
|
|
30
|
+
RS256 = auto()
|
|
31
|
+
HS256 = auto()
|
|
32
|
+
NOT_DEFINED = auto()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AppliesToState(Enum):
|
|
36
|
+
"""Assertion applies-to state enumeration."""
|
|
37
|
+
|
|
38
|
+
ENCRYPTED = "encrypted"
|
|
39
|
+
UNENCRYPTED = "unencrypted"
|
|
40
|
+
|
|
41
|
+
def __str__(self):
|
|
42
|
+
return self.value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BindingMethod(Enum):
|
|
46
|
+
"""Assertion binding method enumeration."""
|
|
47
|
+
|
|
48
|
+
JWS = "jws"
|
|
49
|
+
|
|
50
|
+
def __str__(self):
|
|
51
|
+
return self.value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AssertionKey:
|
|
55
|
+
"""Assertion signing key configuration."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, alg: AssertionKeyAlg, key: Any):
|
|
58
|
+
"""Initialize assertion key."""
|
|
59
|
+
self.alg = alg
|
|
60
|
+
self.key = key
|
|
61
|
+
|
|
62
|
+
def is_defined(self):
|
|
63
|
+
return self.alg != AssertionKeyAlg.NOT_DEFINED
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Statement:
|
|
67
|
+
"""Assertion statement with format, schema, and value."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, format: str, schema: str, value: str):
|
|
70
|
+
"""Initialize assertion statement."""
|
|
71
|
+
self.format = format
|
|
72
|
+
self.schema = schema
|
|
73
|
+
self.value = value
|
|
74
|
+
|
|
75
|
+
def __eq__(self, other):
|
|
76
|
+
return (
|
|
77
|
+
isinstance(other, Statement)
|
|
78
|
+
and self.format == other.format
|
|
79
|
+
and self.schema == other.schema
|
|
80
|
+
and self.value == other.value
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def __hash__(self):
|
|
84
|
+
return hash((self.format, self.schema, self.value))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AssertionConfig:
|
|
88
|
+
"""TDF assertion configuration."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
id: str,
|
|
93
|
+
type: Type,
|
|
94
|
+
scope: Scope,
|
|
95
|
+
applies_to_state: AppliesToState,
|
|
96
|
+
statement: Statement,
|
|
97
|
+
signing_key: AssertionKey | None = None,
|
|
98
|
+
):
|
|
99
|
+
"""Initialize assertion configuration."""
|
|
100
|
+
self.id = id
|
|
101
|
+
self.type = type
|
|
102
|
+
self.scope = scope
|
|
103
|
+
self.applies_to_state = applies_to_state
|
|
104
|
+
self.statement = statement
|
|
105
|
+
self.signing_key = signing_key
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Asymmetric encryption and decryption utilities for RSA keys in PEM format."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.backends import default_backend
|
|
7
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
9
|
+
from cryptography.x509 import load_pem_x509_certificate
|
|
10
|
+
|
|
11
|
+
from .sdk_exceptions import SDKException
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsymDecryption:
|
|
15
|
+
"""Provides functionality for asymmetric decryption using an RSA private key.
|
|
16
|
+
|
|
17
|
+
Supports both PEM string and key object initialization for flexibility.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
CIPHER_TRANSFORM = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"
|
|
21
|
+
PRIVATE_KEY_HEADER = "-----BEGIN PRIVATE KEY-----"
|
|
22
|
+
PRIVATE_KEY_FOOTER = "-----END PRIVATE KEY-----"
|
|
23
|
+
|
|
24
|
+
def __init__(self, private_key_pem: str | None = None, private_key_obj=None):
|
|
25
|
+
"""Initialize with either a PEM string or a key object.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
private_key_pem: Private key in PEM format (with or without headers)
|
|
29
|
+
private_key_obj: Pre-loaded private key object from cryptography library
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
SDKException: If key loading fails
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
if private_key_obj is not None:
|
|
36
|
+
self.private_key = private_key_obj
|
|
37
|
+
elif private_key_pem is not None:
|
|
38
|
+
try:
|
|
39
|
+
# Try direct PEM loading first (most common case)
|
|
40
|
+
try:
|
|
41
|
+
self.private_key = serialization.load_pem_private_key(
|
|
42
|
+
private_key_pem.encode(),
|
|
43
|
+
password=None,
|
|
44
|
+
backend=default_backend(),
|
|
45
|
+
)
|
|
46
|
+
except Exception:
|
|
47
|
+
# Fallback: strip headers and load as DER (for base64-only keys)
|
|
48
|
+
private_key_pem = (
|
|
49
|
+
private_key_pem.replace(self.PRIVATE_KEY_HEADER, "")
|
|
50
|
+
.replace(self.PRIVATE_KEY_FOOTER, "")
|
|
51
|
+
.replace("\n", "")
|
|
52
|
+
.replace("\r", "")
|
|
53
|
+
.replace(" ", "")
|
|
54
|
+
)
|
|
55
|
+
decoded = base64.b64decode(private_key_pem)
|
|
56
|
+
self.private_key = serialization.load_der_private_key(
|
|
57
|
+
decoded, password=None, backend=default_backend()
|
|
58
|
+
)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
raise SDKException(f"Failed to load private key: {e}") from e
|
|
61
|
+
else:
|
|
62
|
+
self.private_key = None
|
|
63
|
+
|
|
64
|
+
def decrypt(self, data: bytes) -> bytes:
|
|
65
|
+
"""Decrypt data using RSA OAEP with SHA-1.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
data: Encrypted bytes to decrypt
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Decrypted bytes
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
SDKException: If decryption fails or key is not set
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
if self.private_key is None:
|
|
78
|
+
raise SDKException("Failed to decrypt, private key is empty")
|
|
79
|
+
try:
|
|
80
|
+
return self.private_key.decrypt(
|
|
81
|
+
data,
|
|
82
|
+
padding.OAEP(
|
|
83
|
+
mgf=padding.MGF1(algorithm=hashes.SHA1()),
|
|
84
|
+
algorithm=hashes.SHA1(),
|
|
85
|
+
label=None,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise SDKException(f"Error performing decryption: {e}") from e
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AsymEncryption:
|
|
93
|
+
"""Provides functionality for asymmetric encryption using an RSA public key or certificate in PEM format.
|
|
94
|
+
|
|
95
|
+
Supports PEM public keys, X.509 certificates, and pre-loaded key objects.
|
|
96
|
+
Also handles base64-encoded keys without PEM headers.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
PUBLIC_KEY_HEADER = "-----BEGIN PUBLIC KEY-----"
|
|
100
|
+
PUBLIC_KEY_FOOTER = "-----END PUBLIC KEY-----"
|
|
101
|
+
CIPHER_TRANSFORM = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"
|
|
102
|
+
|
|
103
|
+
def __init__(self, public_key_pem: str | None = None, public_key_obj=None):
|
|
104
|
+
"""Initialize with either a PEM string or a key object.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
public_key_pem: Public key in PEM format, X.509 certificate, or base64 string
|
|
108
|
+
public_key_obj: Pre-loaded public key object from cryptography library
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
SDKException: If key loading fails or key is not RSA
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
if public_key_obj is not None:
|
|
115
|
+
self.public_key = public_key_obj
|
|
116
|
+
elif public_key_pem is not None:
|
|
117
|
+
try:
|
|
118
|
+
if "BEGIN CERTIFICATE" in public_key_pem:
|
|
119
|
+
# Load from X.509 certificate
|
|
120
|
+
cert = load_pem_x509_certificate(
|
|
121
|
+
public_key_pem.encode(), default_backend()
|
|
122
|
+
)
|
|
123
|
+
self.public_key = cert.public_key()
|
|
124
|
+
else:
|
|
125
|
+
# Try direct PEM loading first (most common case)
|
|
126
|
+
try:
|
|
127
|
+
self.public_key = serialization.load_pem_public_key(
|
|
128
|
+
public_key_pem.encode(), backend=default_backend()
|
|
129
|
+
)
|
|
130
|
+
except Exception:
|
|
131
|
+
# Fallback: strip headers and load as DER (for base64-only keys)
|
|
132
|
+
pem_body = re.sub(r"-----BEGIN (.*)-----", "", public_key_pem)
|
|
133
|
+
pem_body = re.sub(r"-----END (.*)-----", "", pem_body)
|
|
134
|
+
pem_body = re.sub(r"\s", "", pem_body)
|
|
135
|
+
decoded = base64.b64decode(pem_body)
|
|
136
|
+
self.public_key = serialization.load_der_public_key(
|
|
137
|
+
decoded, backend=default_backend()
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
raise SDKException(f"Failed to load public key: {e}") from e
|
|
141
|
+
else:
|
|
142
|
+
self.public_key = None
|
|
143
|
+
|
|
144
|
+
# Validate that it's an RSA key
|
|
145
|
+
if self.public_key is not None and not isinstance(
|
|
146
|
+
self.public_key, rsa.RSAPublicKey
|
|
147
|
+
):
|
|
148
|
+
raise SDKException("Not an RSA PEM formatted public key")
|
|
149
|
+
|
|
150
|
+
def encrypt(self, data: bytes) -> bytes:
|
|
151
|
+
"""Encrypt data using RSA OAEP with SHA-1.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
data: Plaintext bytes to encrypt
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Encrypted bytes
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
SDKException: If encryption fails or key is not set
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
if self.public_key is None:
|
|
164
|
+
raise SDKException("Failed to encrypt, public key is empty")
|
|
165
|
+
try:
|
|
166
|
+
return self.public_key.encrypt(
|
|
167
|
+
data,
|
|
168
|
+
padding.OAEP(
|
|
169
|
+
mgf=padding.MGF1(algorithm=hashes.SHA1()),
|
|
170
|
+
algorithm=hashes.SHA1(),
|
|
171
|
+
label=None,
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
raise SDKException(f"Error performing encryption: {e}") from e
|
|
176
|
+
|
|
177
|
+
def public_key_in_pem_format(self) -> str:
|
|
178
|
+
"""Export the public key to PEM format.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Public key as PEM-encoded string
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
SDKException: If export fails
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
pem = self.public_key.public_bytes(
|
|
189
|
+
encoding=serialization.Encoding.PEM,
|
|
190
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
191
|
+
)
|
|
192
|
+
return pem.decode()
|
|
193
|
+
except Exception as e:
|
|
194
|
+
raise SDKException(f"Error exporting public key to PEM: {e}") from e
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Authentication header management."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class AuthHeaders:
|
|
8
|
+
"""Represents authentication headers used in token-based authorization.
|
|
9
|
+
|
|
10
|
+
This class holds authorization and DPoP (Demonstrating Proof of Possession) headers
|
|
11
|
+
that are used in token-based API requests.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
auth_header: str
|
|
15
|
+
dpop_header: str = ""
|
|
16
|
+
|
|
17
|
+
def get_auth_header(self) -> str:
|
|
18
|
+
"""Get the authorization header."""
|
|
19
|
+
return self.auth_header
|
|
20
|
+
|
|
21
|
+
def get_dpop_header(self) -> str:
|
|
22
|
+
"""Get the DPoP header."""
|
|
23
|
+
return self.dpop_header
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, str]:
|
|
26
|
+
"""Convert authentication headers to a dictionary for use with HTTP clients.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Dictionary with 'Authorization' header and optionally 'DPoP' header
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
headers = {"Authorization": self.auth_header}
|
|
33
|
+
if self.dpop_header:
|
|
34
|
+
headers["DPoP"] = self.dpop_header
|
|
35
|
+
return headers
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Utilities for automatic SDK configuration."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import urllib.parse
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# RuleType constants
|
|
10
|
+
class RuleType:
|
|
11
|
+
"""Rule type constants for attribute hierarchy."""
|
|
12
|
+
|
|
13
|
+
HIERARCHY = "hierarchy"
|
|
14
|
+
ALL_OF = "allOf"
|
|
15
|
+
ANY_OF = "anyOf"
|
|
16
|
+
UNSPECIFIED = "unspecified"
|
|
17
|
+
EMPTY_TERM = "DEFAULT"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class KeySplitStep:
|
|
22
|
+
"""Key split step information."""
|
|
23
|
+
|
|
24
|
+
kas: str
|
|
25
|
+
splitID: str
|
|
26
|
+
|
|
27
|
+
def __str__(self):
|
|
28
|
+
return f"KeySplitStep{{kas={self.kas}, splitID={self.splitID}}}"
|
|
29
|
+
|
|
30
|
+
def __eq__(self, other: Any) -> bool:
|
|
31
|
+
if not isinstance(other, KeySplitStep):
|
|
32
|
+
return False
|
|
33
|
+
return self.kas == other.kas and self.splitID == other.splitID
|
|
34
|
+
|
|
35
|
+
def __hash__(self):
|
|
36
|
+
return hash((self.kas, self.splitID))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AutoConfigureException(Exception):
|
|
40
|
+
"""Exception for auto-configuration errors."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AttributeNameFQN:
|
|
44
|
+
"""Fully qualified attribute name."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, url: str):
|
|
47
|
+
"""Initialize attribute name from URL."""
|
|
48
|
+
pattern = re.compile(r"^(https?://[\w./-]+)/attr/([^/\s]*)$")
|
|
49
|
+
matcher = pattern.match(url)
|
|
50
|
+
if not matcher or not matcher.group(1) or not matcher.group(2):
|
|
51
|
+
raise AutoConfigureException("invalid type: attribute regex fail")
|
|
52
|
+
try:
|
|
53
|
+
urllib.parse.unquote(matcher.group(2))
|
|
54
|
+
except Exception as err:
|
|
55
|
+
raise AutoConfigureException(
|
|
56
|
+
f"invalid type: error in attribute name [{matcher.group(2)}]"
|
|
57
|
+
) from err
|
|
58
|
+
self.url = url
|
|
59
|
+
self.key = url.lower()
|
|
60
|
+
|
|
61
|
+
def __str__(self):
|
|
62
|
+
return self.url
|
|
63
|
+
|
|
64
|
+
def select(self, value: str):
|
|
65
|
+
new_url = f"{self.url}/value/{urllib.parse.quote(value)}"
|
|
66
|
+
return AttributeValueFQN(new_url)
|
|
67
|
+
|
|
68
|
+
def prefix(self):
|
|
69
|
+
return self.url
|
|
70
|
+
|
|
71
|
+
def get_key(self):
|
|
72
|
+
return self.key
|
|
73
|
+
|
|
74
|
+
def authority(self):
|
|
75
|
+
pattern = re.compile(r"^(https?://[\w./-]+)/attr/[^/\s]*$")
|
|
76
|
+
matcher = pattern.match(self.url)
|
|
77
|
+
if not matcher:
|
|
78
|
+
raise AutoConfigureException("invalid type")
|
|
79
|
+
return matcher.group(1)
|
|
80
|
+
|
|
81
|
+
def name(self):
|
|
82
|
+
pattern = re.compile(r"^https?://[\w./-]+/attr/([^/\s]*)$")
|
|
83
|
+
matcher = pattern.match(self.url)
|
|
84
|
+
if not matcher:
|
|
85
|
+
raise AutoConfigureException("invalid attribute")
|
|
86
|
+
try:
|
|
87
|
+
return urllib.parse.unquote(matcher.group(1))
|
|
88
|
+
except Exception as err:
|
|
89
|
+
raise AutoConfigureException("invalid type") from err
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AttributeValueFQN:
|
|
93
|
+
"""Fully qualified attribute value."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, url: str):
|
|
96
|
+
"""Initialize attribute value from URL."""
|
|
97
|
+
pattern = re.compile(r"^(https?://[\w./-]+)/attr/(\S*)/value/(\S*)$")
|
|
98
|
+
matcher = pattern.match(url)
|
|
99
|
+
if (
|
|
100
|
+
not matcher
|
|
101
|
+
or not matcher.group(1)
|
|
102
|
+
or not matcher.group(2)
|
|
103
|
+
or not matcher.group(3)
|
|
104
|
+
):
|
|
105
|
+
raise AutoConfigureException(
|
|
106
|
+
f"invalid type: attribute regex fail for [{url}]"
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
urllib.parse.unquote(matcher.group(2))
|
|
110
|
+
urllib.parse.unquote(matcher.group(3))
|
|
111
|
+
except Exception as err:
|
|
112
|
+
raise AutoConfigureException(
|
|
113
|
+
"invalid type: error in attribute or value"
|
|
114
|
+
) from err
|
|
115
|
+
self.url = url
|
|
116
|
+
self.key = url.lower()
|
|
117
|
+
|
|
118
|
+
def __str__(self):
|
|
119
|
+
return self.url
|
|
120
|
+
|
|
121
|
+
def __eq__(self, other: Any) -> bool:
|
|
122
|
+
if not isinstance(other, AttributeValueFQN):
|
|
123
|
+
return False
|
|
124
|
+
return self.key == other.key
|
|
125
|
+
|
|
126
|
+
def __hash__(self):
|
|
127
|
+
return hash(self.key)
|