mip-wrapper 0.1.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.
- mip_wrapper/__init__.py +47 -0
- mip_wrapper/artifacts.py +82 -0
- mip_wrapper/auth.py +101 -0
- mip_wrapper/bridge/__init__.py +17 -0
- mip_wrapper/bridge/client.py +271 -0
- mip_wrapper/bridge/protocol.py +159 -0
- mip_wrapper/client.py +260 -0
- mip_wrapper/exceptions.py +90 -0
- mip_wrapper/version.py +55 -0
- mip_wrapper-0.1.0.dist-info/METADATA +103 -0
- mip_wrapper-0.1.0.dist-info/RECORD +14 -0
- mip_wrapper-0.1.0.dist-info/WHEEL +5 -0
- mip_wrapper-0.1.0.dist-info/licenses/LICENSE +21 -0
- mip_wrapper-0.1.0.dist-info/top_level.txt +1 -0
mip_wrapper/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""MIP Wrapper: Python interface for Microsoft Information Protection decryption."""
|
|
2
|
+
|
|
3
|
+
from mip_wrapper.client import MipClient
|
|
4
|
+
from mip_wrapper.exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
AuthorizationError,
|
|
7
|
+
CleanupError,
|
|
8
|
+
DecryptionError,
|
|
9
|
+
DestinationError,
|
|
10
|
+
InvalidConfigurationError,
|
|
11
|
+
MipError,
|
|
12
|
+
MissingRuntimeError,
|
|
13
|
+
NativeRuntimeError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
ProtocolError,
|
|
16
|
+
UnsupportedFileTypeError,
|
|
17
|
+
UnsupportedProtectionError,
|
|
18
|
+
)
|
|
19
|
+
from mip_wrapper.auth import AuthBase, CertificateAuth, ClientSecretAuth
|
|
20
|
+
from mip_wrapper.artifacts import DecryptedFile, FileInfo
|
|
21
|
+
from mip_wrapper.version import PACKAGE_VERSION, PROTOCOL_VERSION
|
|
22
|
+
|
|
23
|
+
__version__ = PACKAGE_VERSION
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"MipClient",
|
|
27
|
+
"MipError",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"AuthorizationError",
|
|
30
|
+
"PermissionDeniedError",
|
|
31
|
+
"UnsupportedProtectionError",
|
|
32
|
+
"UnsupportedFileTypeError",
|
|
33
|
+
"InvalidConfigurationError",
|
|
34
|
+
"NativeRuntimeError",
|
|
35
|
+
"ProtocolError",
|
|
36
|
+
"DecryptionError",
|
|
37
|
+
"CleanupError",
|
|
38
|
+
"DestinationError",
|
|
39
|
+
"MissingRuntimeError",
|
|
40
|
+
"AuthBase",
|
|
41
|
+
"CertificateAuth",
|
|
42
|
+
"ClientSecretAuth",
|
|
43
|
+
"DecryptedFile",
|
|
44
|
+
"FileInfo",
|
|
45
|
+
"PACKAGE_VERSION",
|
|
46
|
+
"PROTOCOL_VERSION",
|
|
47
|
+
]
|
mip_wrapper/artifacts.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Decrypted file artifacts."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DecryptedFile:
|
|
8
|
+
"""Artifact representing a decrypted temporary file."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
path: Path,
|
|
13
|
+
filename: str,
|
|
14
|
+
file_format: str,
|
|
15
|
+
size_bytes: int | None = None,
|
|
16
|
+
audit_metadata: dict[str, str] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Initialize decrypted file artifact.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
path: Path to the decrypted file
|
|
23
|
+
filename: Original filename
|
|
24
|
+
file_format: File extension (.xlsx, .pdf, etc.)
|
|
25
|
+
size_bytes: Size of decrypted file (if known)
|
|
26
|
+
audit_metadata: Safe audit metadata
|
|
27
|
+
"""
|
|
28
|
+
self.path = path
|
|
29
|
+
self.filename = filename
|
|
30
|
+
self.file_format = file_format
|
|
31
|
+
self.size_bytes = size_bytes
|
|
32
|
+
self.audit_metadata = audit_metadata or {}
|
|
33
|
+
self.decryption_timestamp = datetime.now(timezone.utc)
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return f"DecryptedFile(path={self.path}, filename={self.filename})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FileInfo:
|
|
40
|
+
"""Metadata about a protected file."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
is_protected: bool,
|
|
45
|
+
label_id: str | None,
|
|
46
|
+
tenant_id: str | None,
|
|
47
|
+
file_format: str,
|
|
48
|
+
protection_type: str,
|
|
49
|
+
usage_rights: list[str],
|
|
50
|
+
can_decrypt: bool,
|
|
51
|
+
sdk_version: str,
|
|
52
|
+
helper_version: str,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Initialize file info.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
is_protected: Whether file is MIP-protected
|
|
59
|
+
label_id: Sensitivity label ID (if available)
|
|
60
|
+
tenant_id: Tenant ID where file is protected
|
|
61
|
+
file_format: File extension
|
|
62
|
+
protection_type: Type of protection (azure_rms, etc.)
|
|
63
|
+
usage_rights: Available rights for current user
|
|
64
|
+
can_decrypt: Whether current config can decrypt
|
|
65
|
+
sdk_version: MIP SDK version
|
|
66
|
+
helper_version: Helper version
|
|
67
|
+
"""
|
|
68
|
+
self.is_protected = is_protected
|
|
69
|
+
self.label_id = label_id
|
|
70
|
+
self.tenant_id = tenant_id
|
|
71
|
+
self.file_format = file_format
|
|
72
|
+
self.protection_type = protection_type
|
|
73
|
+
self.usage_rights = usage_rights
|
|
74
|
+
self.can_decrypt = can_decrypt
|
|
75
|
+
self.sdk_version = sdk_version
|
|
76
|
+
self.helper_version = helper_version
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
return (
|
|
80
|
+
f"FileInfo(protected={self.is_protected}, "
|
|
81
|
+
f"label={self.label_id}, rights={self.usage_rights})"
|
|
82
|
+
)
|
mip_wrapper/auth.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Authentication configuration."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mip_wrapper.exceptions import InvalidConfigurationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthBase(ABC):
|
|
10
|
+
"""Base class for authentication methods."""
|
|
11
|
+
|
|
12
|
+
tenant_id: str
|
|
13
|
+
client_id: str
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def validate(self) -> None:
|
|
17
|
+
"""Validate configuration is complete."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CertificateAuth(AuthBase):
|
|
22
|
+
"""Certificate-based authentication (recommended)."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
tenant_id: str,
|
|
27
|
+
client_id: str,
|
|
28
|
+
certificate_path: str,
|
|
29
|
+
certificate_password_path: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Configure certificate-based authentication.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
tenant_id: Azure AD tenant ID (UUID or domain)
|
|
36
|
+
client_id: App registration client ID (UUID)
|
|
37
|
+
certificate_path: Path to .pfx certificate file
|
|
38
|
+
certificate_password_path: Path to file containing certificate password
|
|
39
|
+
(optional if cert has no password)
|
|
40
|
+
"""
|
|
41
|
+
self.tenant_id = tenant_id
|
|
42
|
+
self.client_id = client_id
|
|
43
|
+
self.certificate_path = certificate_path
|
|
44
|
+
self.certificate_password_path = certificate_password_path
|
|
45
|
+
|
|
46
|
+
def validate(self) -> None:
|
|
47
|
+
"""Validate certificate paths exist."""
|
|
48
|
+
if not self.tenant_id or not self.client_id:
|
|
49
|
+
raise InvalidConfigurationError(
|
|
50
|
+
"tenant_id and client_id are required",
|
|
51
|
+
error_code="MissingAuth",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
cert_path = Path(self.certificate_path)
|
|
55
|
+
if not cert_path.exists():
|
|
56
|
+
raise InvalidConfigurationError(
|
|
57
|
+
f"Certificate file not found: {self.certificate_path}",
|
|
58
|
+
error_code="CertificateNotFound",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if self.certificate_password_path:
|
|
62
|
+
pwd_path = Path(self.certificate_password_path)
|
|
63
|
+
if not pwd_path.exists():
|
|
64
|
+
raise InvalidConfigurationError(
|
|
65
|
+
f"Certificate password file not found: {self.certificate_password_path}",
|
|
66
|
+
error_code="PasswordFileNotFound",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ClientSecretAuth(AuthBase):
|
|
71
|
+
"""Client secret authentication (less secure, for development)."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
tenant_id: str,
|
|
76
|
+
client_id: str,
|
|
77
|
+
secret_provider: callable,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Configure client secret authentication.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
tenant_id: Azure AD tenant ID
|
|
84
|
+
client_id: App registration client ID
|
|
85
|
+
secret_provider: Callable that returns the client secret
|
|
86
|
+
|
|
87
|
+
Note:
|
|
88
|
+
Client secrets should be stored securely (Key Vault, etc.)
|
|
89
|
+
This method is less secure than certificate authentication.
|
|
90
|
+
"""
|
|
91
|
+
self.tenant_id = tenant_id
|
|
92
|
+
self.client_id = client_id
|
|
93
|
+
self.secret_provider = secret_provider
|
|
94
|
+
|
|
95
|
+
def validate(self) -> None:
|
|
96
|
+
"""Validate configuration."""
|
|
97
|
+
if not self.tenant_id or not self.client_id:
|
|
98
|
+
raise InvalidConfigurationError(
|
|
99
|
+
"tenant_id and client_id are required",
|
|
100
|
+
error_code="MissingAuth",
|
|
101
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Bridge between Python and .NET helper."""
|
|
2
|
+
|
|
3
|
+
from mip_wrapper.bridge.client import HelperClient
|
|
4
|
+
from mip_wrapper.bridge.protocol import (
|
|
5
|
+
DecryptResult,
|
|
6
|
+
InspectResult,
|
|
7
|
+
ProtocolRequest,
|
|
8
|
+
ProtocolResponse,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HelperClient",
|
|
13
|
+
"ProtocolRequest",
|
|
14
|
+
"ProtocolResponse",
|
|
15
|
+
"InspectResult",
|
|
16
|
+
"DecryptResult",
|
|
17
|
+
]
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Bridge client: Python process management of .NET helper."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import queue
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mip_wrapper.exceptions import NativeRuntimeError, ProtocolError
|
|
13
|
+
from mip_wrapper.bridge.protocol import (
|
|
14
|
+
DecryptResult,
|
|
15
|
+
InspectResult,
|
|
16
|
+
ProtocolRequest,
|
|
17
|
+
ProtocolResponse,
|
|
18
|
+
)
|
|
19
|
+
from mip_wrapper.version import check_helper_version, check_protocol_version
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HelperClient:
|
|
25
|
+
"""Manages the .NET MipWrapper.Helper process."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, helper_path: Path, timeout_seconds: int = 120) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Initialize helper client.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
helper_path: Path to MipWrapper.Helper executable
|
|
33
|
+
timeout_seconds: Process timeout for operations
|
|
34
|
+
"""
|
|
35
|
+
self.helper_path = helper_path
|
|
36
|
+
self.timeout_seconds = timeout_seconds
|
|
37
|
+
self._process: subprocess.Popen[str] | None = None
|
|
38
|
+
self._stdout_queue: queue.Queue[str | None] | None = None
|
|
39
|
+
logger.debug(f"HelperClient initialized: {helper_path}")
|
|
40
|
+
|
|
41
|
+
def _read_stdout(self, process: subprocess.Popen[str], output: queue.Queue[str | None]) -> None:
|
|
42
|
+
if process.stdout is None:
|
|
43
|
+
output.put(None)
|
|
44
|
+
return
|
|
45
|
+
try:
|
|
46
|
+
for line in iter(process.stdout.readline, ""):
|
|
47
|
+
if not isinstance(line, str):
|
|
48
|
+
break
|
|
49
|
+
output.put(line)
|
|
50
|
+
finally:
|
|
51
|
+
output.put(None)
|
|
52
|
+
|
|
53
|
+
def _drain_stderr(self, process: subprocess.Popen[str]) -> None:
|
|
54
|
+
if process.stderr is None:
|
|
55
|
+
return
|
|
56
|
+
for line in iter(process.stderr.readline, ""):
|
|
57
|
+
if not isinstance(line, str):
|
|
58
|
+
break
|
|
59
|
+
sys.stderr.write(line)
|
|
60
|
+
sys.stderr.flush()
|
|
61
|
+
|
|
62
|
+
def _spawn_helper(self) -> subprocess.Popen[str]:
|
|
63
|
+
"""Spawn helper process if not already running."""
|
|
64
|
+
if self._process is not None:
|
|
65
|
+
return self._process
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
self._process = subprocess.Popen(
|
|
69
|
+
[str(self.helper_path)],
|
|
70
|
+
stdin=subprocess.PIPE,
|
|
71
|
+
stdout=subprocess.PIPE,
|
|
72
|
+
stderr=subprocess.PIPE,
|
|
73
|
+
text=True,
|
|
74
|
+
bufsize=1,
|
|
75
|
+
)
|
|
76
|
+
self._stdout_queue = queue.Queue()
|
|
77
|
+
threading.Thread(target=self._read_stdout, args=(self._process, self._stdout_queue), daemon=True).start()
|
|
78
|
+
threading.Thread(target=self._drain_stderr, args=(self._process,), daemon=True).start()
|
|
79
|
+
logger.debug("Helper process spawned")
|
|
80
|
+
except FileNotFoundError as e:
|
|
81
|
+
raise NativeRuntimeError(
|
|
82
|
+
f"Helper executable not found: {self.helper_path}",
|
|
83
|
+
error_code="HelperNotFound",
|
|
84
|
+
) from e
|
|
85
|
+
except Exception as e:
|
|
86
|
+
raise NativeRuntimeError(
|
|
87
|
+
f"Failed to spawn helper: {e}",
|
|
88
|
+
error_code="HelperSpawnFailed",
|
|
89
|
+
) from e
|
|
90
|
+
|
|
91
|
+
return self._process
|
|
92
|
+
|
|
93
|
+
def _terminate_helper(self) -> None:
|
|
94
|
+
process = self._process
|
|
95
|
+
self._process = None
|
|
96
|
+
self._stdout_queue = None
|
|
97
|
+
if process is None:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
try:
|
|
102
|
+
process.terminate()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
process.wait(timeout=2)
|
|
108
|
+
except Exception:
|
|
109
|
+
try:
|
|
110
|
+
process.kill()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
try:
|
|
114
|
+
process.wait(timeout=2)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
finally:
|
|
118
|
+
for pipe in (process.stdin, process.stdout, process.stderr):
|
|
119
|
+
if pipe is not None:
|
|
120
|
+
try:
|
|
121
|
+
pipe.close()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def _send_request(self, request: ProtocolRequest) -> ProtocolResponse:
|
|
126
|
+
"""Send request to helper and get response."""
|
|
127
|
+
process = self._spawn_helper()
|
|
128
|
+
|
|
129
|
+
if process.stdin is None or process.stdout is None:
|
|
130
|
+
raise NativeRuntimeError(
|
|
131
|
+
"Helper process pipes are not available",
|
|
132
|
+
error_code="HelperPipeFailed",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
request_json = request.to_json()
|
|
137
|
+
logger.debug(f"Sending request: {request.command}")
|
|
138
|
+
process.stdin.write(request_json + "\n")
|
|
139
|
+
process.stdin.flush()
|
|
140
|
+
|
|
141
|
+
if self._stdout_queue is None:
|
|
142
|
+
raise NativeRuntimeError(
|
|
143
|
+
"Helper stdout reader is not available",
|
|
144
|
+
error_code="HelperPipeFailed",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
response_json = self._stdout_queue.get(
|
|
149
|
+
timeout=request.timeout_seconds or self.timeout_seconds
|
|
150
|
+
)
|
|
151
|
+
except queue.Empty as error:
|
|
152
|
+
self._terminate_helper()
|
|
153
|
+
raise NativeRuntimeError(
|
|
154
|
+
f"Helper operation timed out: {request.command}",
|
|
155
|
+
error_code="HelperTimeout",
|
|
156
|
+
) from error
|
|
157
|
+
|
|
158
|
+
if response_json is None:
|
|
159
|
+
self._terminate_helper()
|
|
160
|
+
raise ProtocolError(
|
|
161
|
+
f"Helper exited before responding to {request.command}",
|
|
162
|
+
error_code="HelperTerminated",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if not response_json:
|
|
166
|
+
raise ProtocolError(
|
|
167
|
+
"Helper process terminated without response",
|
|
168
|
+
error_code="HelperTerminated",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
logger.debug("Received response from helper")
|
|
172
|
+
response = ProtocolResponse.from_json(response_json)
|
|
173
|
+
|
|
174
|
+
# Validate protocol version
|
|
175
|
+
try:
|
|
176
|
+
check_protocol_version(response.protocol_version)
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
raise ProtocolError(str(e), error_code="ProtocolVersionMismatch") from e
|
|
179
|
+
|
|
180
|
+
return response
|
|
181
|
+
|
|
182
|
+
except (BrokenPipeError, OSError) as e:
|
|
183
|
+
raise ProtocolError(
|
|
184
|
+
f"Communication with helper failed: {e}",
|
|
185
|
+
error_code="ProtocolFailed",
|
|
186
|
+
) from e
|
|
187
|
+
|
|
188
|
+
def inspect(
|
|
189
|
+
self,
|
|
190
|
+
tenant_id: str,
|
|
191
|
+
client_id: str,
|
|
192
|
+
certificate_path: str,
|
|
193
|
+
authorization_mode: str,
|
|
194
|
+
delegated_user: str | None,
|
|
195
|
+
source_path: str,
|
|
196
|
+
timeout_seconds: int | None = None,
|
|
197
|
+
client_secret: str | None = None,
|
|
198
|
+
) -> InspectResult:
|
|
199
|
+
"""
|
|
200
|
+
Inspect file protection metadata.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
InspectResult with file metadata
|
|
204
|
+
"""
|
|
205
|
+
request = ProtocolRequest(
|
|
206
|
+
command="inspect",
|
|
207
|
+
tenant_id=tenant_id,
|
|
208
|
+
client_id=client_id,
|
|
209
|
+
certificate_path=certificate_path,
|
|
210
|
+
authorization_mode=authorization_mode,
|
|
211
|
+
delegated_user=delegated_user,
|
|
212
|
+
source_path=source_path,
|
|
213
|
+
timeout_seconds=timeout_seconds or self.timeout_seconds,
|
|
214
|
+
client_secret=client_secret,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
response = self._send_request(request)
|
|
218
|
+
data = response.ensure_success()
|
|
219
|
+
return InspectResult.from_response(data)
|
|
220
|
+
|
|
221
|
+
def decrypt(
|
|
222
|
+
self,
|
|
223
|
+
tenant_id: str,
|
|
224
|
+
client_id: str,
|
|
225
|
+
certificate_path: str,
|
|
226
|
+
authorization_mode: str,
|
|
227
|
+
delegated_user: str | None,
|
|
228
|
+
source_path: str,
|
|
229
|
+
output_path: str,
|
|
230
|
+
timeout_seconds: int | None = None,
|
|
231
|
+
client_secret: str | None = None,
|
|
232
|
+
) -> DecryptResult:
|
|
233
|
+
"""
|
|
234
|
+
Decrypt file to output path.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
DecryptResult with output path and size
|
|
238
|
+
"""
|
|
239
|
+
request = ProtocolRequest(
|
|
240
|
+
command="decrypt",
|
|
241
|
+
tenant_id=tenant_id,
|
|
242
|
+
client_id=client_id,
|
|
243
|
+
certificate_path=certificate_path,
|
|
244
|
+
authorization_mode=authorization_mode,
|
|
245
|
+
delegated_user=delegated_user,
|
|
246
|
+
source_path=source_path,
|
|
247
|
+
output_path=output_path,
|
|
248
|
+
timeout_seconds=timeout_seconds or self.timeout_seconds,
|
|
249
|
+
client_secret=client_secret,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
response = self._send_request(request)
|
|
253
|
+
data = response.ensure_success()
|
|
254
|
+
return DecryptResult.from_response(data)
|
|
255
|
+
|
|
256
|
+
def shutdown(self) -> None:
|
|
257
|
+
"""Shutdown helper process gracefully."""
|
|
258
|
+
if self._process is None:
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
request = ProtocolRequest(command="shutdown")
|
|
263
|
+
self._send_request(request)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
logger.warning(f"Shutdown request failed: {e}")
|
|
266
|
+
finally:
|
|
267
|
+
self._terminate_helper()
|
|
268
|
+
|
|
269
|
+
def __del__(self) -> None:
|
|
270
|
+
"""Cleanup on deletion."""
|
|
271
|
+
self.shutdown()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Protocol v1.0: JSON communication between Python and .NET helper."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PROTOCOL_VERSION = "1.0"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ProtocolRequest:
|
|
14
|
+
"""Request to send to helper."""
|
|
15
|
+
|
|
16
|
+
protocol_version: str = PROTOCOL_VERSION
|
|
17
|
+
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
18
|
+
command: str = ""
|
|
19
|
+
tenant_id: str = ""
|
|
20
|
+
client_id: str = ""
|
|
21
|
+
certificate_path: str = ""
|
|
22
|
+
authorization_mode: str = ""
|
|
23
|
+
delegated_user: str | None = None
|
|
24
|
+
source_path: str | None = None
|
|
25
|
+
output_path: str | None = None
|
|
26
|
+
timeout_seconds: int = 120
|
|
27
|
+
client_secret: str | None = None
|
|
28
|
+
|
|
29
|
+
def to_json(self) -> str:
|
|
30
|
+
"""Serialize to JSON."""
|
|
31
|
+
data = asdict(self)
|
|
32
|
+
data = {k: v for k, v in data.items() if v is not None}
|
|
33
|
+
return json.dumps(data)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict[str, Any]) -> "ProtocolRequest":
|
|
37
|
+
"""Create from dictionary."""
|
|
38
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ProtocolResponse:
|
|
43
|
+
"""Response from helper."""
|
|
44
|
+
|
|
45
|
+
protocol_version: str
|
|
46
|
+
request_id: str
|
|
47
|
+
success: bool
|
|
48
|
+
result: dict[str, Any] | None = None
|
|
49
|
+
error: dict[str, str] | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_json(cls, json_str: str) -> "ProtocolResponse":
|
|
53
|
+
"""Parse from JSON."""
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(json_str)
|
|
56
|
+
except json.JSONDecodeError as e:
|
|
57
|
+
raise ValueError(f"Invalid JSON response: {e}") from e
|
|
58
|
+
|
|
59
|
+
return cls(
|
|
60
|
+
protocol_version=data.get("protocol_version", ""),
|
|
61
|
+
request_id=data.get("request_id", ""),
|
|
62
|
+
success=data.get("success", False),
|
|
63
|
+
result=data.get("result"),
|
|
64
|
+
error=data.get("error"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def validate_version(self) -> None:
|
|
68
|
+
"""Validate protocol version matches."""
|
|
69
|
+
if self.protocol_version != PROTOCOL_VERSION:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Unsupported protocol version: {self.protocol_version} "
|
|
72
|
+
f"(expected {PROTOCOL_VERSION})"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def ensure_success(self) -> dict[str, Any]:
|
|
76
|
+
"""Raise if not successful."""
|
|
77
|
+
if not self.success:
|
|
78
|
+
from mip_wrapper.exceptions import (
|
|
79
|
+
DecryptionError,
|
|
80
|
+
PermissionDeniedError,
|
|
81
|
+
UnsupportedFileTypeError,
|
|
82
|
+
UnsupportedProtectionError,
|
|
83
|
+
ProtocolError,
|
|
84
|
+
NativeRuntimeError,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
error = self.error or {}
|
|
88
|
+
code = error.get("code", "UnknownError")
|
|
89
|
+
message = error.get("message", "Unknown error")
|
|
90
|
+
|
|
91
|
+
# Map error codes to typed exceptions
|
|
92
|
+
error_map = {
|
|
93
|
+
"PermissionDenied": PermissionDeniedError,
|
|
94
|
+
"Unauthorized": PermissionDeniedError,
|
|
95
|
+
"UnsupportedFormat": UnsupportedFileTypeError,
|
|
96
|
+
"UnsupportedProtectionType": UnsupportedProtectionError,
|
|
97
|
+
"ProtocolError": ProtocolError,
|
|
98
|
+
"DecryptionError": DecryptionError,
|
|
99
|
+
"FileNotFound": DecryptionError,
|
|
100
|
+
"CertificateNotFound": NativeRuntimeError,
|
|
101
|
+
"CertificateLoadError": NativeRuntimeError,
|
|
102
|
+
"TokenAcquisitionError": NativeRuntimeError,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
exception_class = error_map.get(code, NativeRuntimeError)
|
|
106
|
+
raise exception_class(
|
|
107
|
+
message,
|
|
108
|
+
error_code=code,
|
|
109
|
+
audit_metadata={"helper_error": code},
|
|
110
|
+
)
|
|
111
|
+
return self.result or {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class InspectResult:
|
|
116
|
+
"""Result of inspect command."""
|
|
117
|
+
|
|
118
|
+
is_protected: bool
|
|
119
|
+
label_id: str | None
|
|
120
|
+
tenant_id: str | None
|
|
121
|
+
file_format: str
|
|
122
|
+
protection_type: str
|
|
123
|
+
usage_rights: list[str]
|
|
124
|
+
can_decrypt: bool
|
|
125
|
+
sdk_version: str
|
|
126
|
+
helper_version: str
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_response(cls, data: dict[str, Any]) -> "InspectResult":
|
|
130
|
+
"""Create from protocol response."""
|
|
131
|
+
return cls(
|
|
132
|
+
is_protected=data.get("is_protected", False),
|
|
133
|
+
label_id=data.get("label_id"),
|
|
134
|
+
tenant_id=data.get("tenant_id"),
|
|
135
|
+
file_format=data.get("file_format", ""),
|
|
136
|
+
protection_type=data.get("protection_type", ""),
|
|
137
|
+
usage_rights=data.get("usage_rights", []),
|
|
138
|
+
can_decrypt=data.get("can_decrypt", False),
|
|
139
|
+
sdk_version=data.get("sdk_version", ""),
|
|
140
|
+
helper_version=data.get("helper_version", ""),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class DecryptResult:
|
|
146
|
+
"""Result of decrypt command."""
|
|
147
|
+
|
|
148
|
+
output_path: str
|
|
149
|
+
size_bytes: int
|
|
150
|
+
file_format: str
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_response(cls, data: dict[str, Any]) -> "DecryptResult":
|
|
154
|
+
"""Create from protocol response."""
|
|
155
|
+
return cls(
|
|
156
|
+
output_path=data.get("output_path", ""),
|
|
157
|
+
size_bytes=data.get("size_bytes", 0),
|
|
158
|
+
file_format=data.get("file_format", ""),
|
|
159
|
+
)
|
mip_wrapper/client.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Main MIP Wrapper client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Generator
|
|
10
|
+
|
|
11
|
+
from mip_wrapper.artifacts import DecryptedFile, FileInfo
|
|
12
|
+
from mip_wrapper.auth import AuthBase, CertificateAuth, ClientSecretAuth
|
|
13
|
+
from mip_wrapper.bridge.client import HelperClient
|
|
14
|
+
from mip_wrapper.exceptions import (
|
|
15
|
+
CleanupError,
|
|
16
|
+
InvalidConfigurationError,
|
|
17
|
+
MissingRuntimeError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MipClient:
|
|
24
|
+
"""Client for MIP file operations."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
auth: AuthBase,
|
|
29
|
+
authorization_mode: str,
|
|
30
|
+
delegated_user: str | None = None,
|
|
31
|
+
helper_path: str | None = None,
|
|
32
|
+
correlation_id: str | None = None,
|
|
33
|
+
timeout_seconds: int = 120,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Initialize MipClient.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
auth: Authentication configuration (CertificateAuth, ClientSecretAuth)
|
|
40
|
+
authorization_mode: "delegated_reader" or "super_user"
|
|
41
|
+
delegated_user: User UPN for delegated_reader mode (required if using that mode)
|
|
42
|
+
helper_path: Path to MipWrapper.Helper executable
|
|
43
|
+
correlation_id: Correlation ID for audit/logging
|
|
44
|
+
timeout_seconds: Operation timeout
|
|
45
|
+
"""
|
|
46
|
+
auth.validate()
|
|
47
|
+
|
|
48
|
+
if authorization_mode != "delegated_reader":
|
|
49
|
+
raise InvalidConfigurationError(
|
|
50
|
+
f"Invalid authorization_mode: {authorization_mode}",
|
|
51
|
+
error_code="InvalidAuthMode",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if authorization_mode == "delegated_reader" and not delegated_user:
|
|
55
|
+
raise InvalidConfigurationError(
|
|
56
|
+
"delegated_user is required for delegated_reader mode",
|
|
57
|
+
error_code="MissingDelegatedUser",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
self.auth = auth
|
|
61
|
+
self.authorization_mode = authorization_mode
|
|
62
|
+
self.delegated_user = delegated_user
|
|
63
|
+
self.correlation_id = correlation_id or ""
|
|
64
|
+
self.timeout_seconds = timeout_seconds
|
|
65
|
+
|
|
66
|
+
# Find helper executable
|
|
67
|
+
if helper_path:
|
|
68
|
+
self.helper_path = Path(helper_path)
|
|
69
|
+
else:
|
|
70
|
+
self.helper_path = self._find_helper()
|
|
71
|
+
|
|
72
|
+
self.helper = HelperClient(self.helper_path, timeout_seconds)
|
|
73
|
+
self._version_checked = False
|
|
74
|
+
|
|
75
|
+
def _check_version_compatibility(self, helper_version: str) -> None:
|
|
76
|
+
"""Check helper version compatibility on first use."""
|
|
77
|
+
if self._version_checked:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
from mip_wrapper.version import check_helper_version
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
check_helper_version(helper_version)
|
|
84
|
+
self._version_checked = True
|
|
85
|
+
logger.info(f"Helper version {helper_version} is compatible")
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
raise InvalidConfigurationError(
|
|
88
|
+
str(e),
|
|
89
|
+
error_code="VersionMismatch",
|
|
90
|
+
) from e
|
|
91
|
+
|
|
92
|
+
def _find_helper(self) -> Path:
|
|
93
|
+
"""
|
|
94
|
+
Find MipWrapper.Helper executable using search order:
|
|
95
|
+
1. MIP_WRAPPER_HELPER_PATH environment variable
|
|
96
|
+
2. Current working directory
|
|
97
|
+
3. Package installation directory (future)
|
|
98
|
+
"""
|
|
99
|
+
# 1. Check environment variable
|
|
100
|
+
env_path = os.environ.get("MIP_WRAPPER_HELPER_PATH")
|
|
101
|
+
if env_path:
|
|
102
|
+
path = Path(env_path)
|
|
103
|
+
if path.exists():
|
|
104
|
+
logger.debug(f"Found helper via MIP_WRAPPER_HELPER_PATH: {path}")
|
|
105
|
+
return path
|
|
106
|
+
logger.warning(f"MIP_WRAPPER_HELPER_PATH set but file not found: {env_path}")
|
|
107
|
+
|
|
108
|
+
# 2. Try common locations
|
|
109
|
+
candidates = [
|
|
110
|
+
Path.cwd() / "MipWrapper.Helper",
|
|
111
|
+
Path.cwd() / "MipWrapper.Helper.exe",
|
|
112
|
+
Path.cwd() / "helper-bin" / "MipWrapper.Helper",
|
|
113
|
+
Path.cwd() / "helper-bin" / "MipWrapper.Helper.exe",
|
|
114
|
+
Path(__file__).parent.parent.parent / "helper-bin" / "MipWrapper.Helper",
|
|
115
|
+
Path(__file__).parent.parent.parent / "helper-bin" / "MipWrapper.Helper.exe",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
for candidate in candidates:
|
|
119
|
+
if candidate.exists():
|
|
120
|
+
logger.debug(f"Found helper at: {candidate}")
|
|
121
|
+
return candidate
|
|
122
|
+
|
|
123
|
+
# 3. Provide clear error with instructions
|
|
124
|
+
raise MissingRuntimeError(
|
|
125
|
+
"MipWrapper.Helper executable not found. "
|
|
126
|
+
"\n\nTo use mip_wrapper, you must:\n"
|
|
127
|
+
"1. Build the .NET helper:\n"
|
|
128
|
+
" cd native/MipWrapper.Helper\n"
|
|
129
|
+
" dotnet publish -c Release -o ../../helper-bin\n"
|
|
130
|
+
"\n2. Make it discoverable:\n"
|
|
131
|
+
" - Set MIP_WRAPPER_HELPER_PATH=/path/to/MipWrapper.Helper\n"
|
|
132
|
+
" - Or place in current directory or helper-bin/\n"
|
|
133
|
+
" - Or pass helper_path parameter to MipClient()\n"
|
|
134
|
+
"\nSee BUILD_LOCALLY.md for detailed instructions.",
|
|
135
|
+
error_code="HelperNotFound",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def inspect(self, source_path: str) -> FileInfo:
|
|
139
|
+
"""
|
|
140
|
+
Inspect file protection metadata.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
source_path: Path to protected file
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
FileInfo with metadata
|
|
147
|
+
"""
|
|
148
|
+
logger.info(f"Inspecting file: {source_path}")
|
|
149
|
+
|
|
150
|
+
# Get client secret if using ClientSecretAuth
|
|
151
|
+
client_secret = None
|
|
152
|
+
if isinstance(self.auth, ClientSecretAuth):
|
|
153
|
+
client_secret = self.auth.secret_provider()
|
|
154
|
+
|
|
155
|
+
result = self.helper.inspect(
|
|
156
|
+
tenant_id=self.auth.tenant_id,
|
|
157
|
+
client_id=self.auth.client_id,
|
|
158
|
+
certificate_path=self.auth.certificate_path
|
|
159
|
+
if isinstance(self.auth, CertificateAuth)
|
|
160
|
+
else "",
|
|
161
|
+
authorization_mode=self.authorization_mode,
|
|
162
|
+
delegated_user=self.delegated_user,
|
|
163
|
+
source_path=source_path,
|
|
164
|
+
client_secret=client_secret,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Check version compatibility on first use
|
|
168
|
+
self._check_version_compatibility(result.helper_version)
|
|
169
|
+
|
|
170
|
+
return FileInfo(
|
|
171
|
+
is_protected=result.is_protected,
|
|
172
|
+
label_id=result.label_id,
|
|
173
|
+
tenant_id=result.tenant_id,
|
|
174
|
+
file_format=result.file_format,
|
|
175
|
+
protection_type=result.protection_type,
|
|
176
|
+
usage_rights=result.usage_rights,
|
|
177
|
+
can_decrypt=result.can_decrypt,
|
|
178
|
+
sdk_version=result.sdk_version,
|
|
179
|
+
helper_version=result.helper_version,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@contextmanager
|
|
183
|
+
def decrypted_file(self, source_path: str) -> Generator[DecryptedFile, None, None]:
|
|
184
|
+
"""
|
|
185
|
+
Context manager for temporary file decryption.
|
|
186
|
+
|
|
187
|
+
Automatically cleans up after use.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
source_path: Path to protected file
|
|
191
|
+
|
|
192
|
+
Yields:
|
|
193
|
+
DecryptedFile artifact with temporary file path
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
PermissionDeniedError: If user lacks Export right
|
|
197
|
+
"""
|
|
198
|
+
temp_dir = None
|
|
199
|
+
try:
|
|
200
|
+
# Create secure temporary directory
|
|
201
|
+
temp_dir = tempfile.mkdtemp(prefix="mipwrapper_", dir=None)
|
|
202
|
+
logger.debug(f"Created temp directory: {temp_dir}")
|
|
203
|
+
temp_path = Path(temp_dir)
|
|
204
|
+
temp_path.chmod(0o700)
|
|
205
|
+
|
|
206
|
+
# Get source filename
|
|
207
|
+
source = Path(source_path)
|
|
208
|
+
output_filename = source.name
|
|
209
|
+
output_path = str(temp_path / output_filename)
|
|
210
|
+
|
|
211
|
+
# Decrypt
|
|
212
|
+
logger.info(f"Decrypting to temp: {source_path}")
|
|
213
|
+
|
|
214
|
+
# Get client secret if using ClientSecretAuth
|
|
215
|
+
client_secret = None
|
|
216
|
+
if isinstance(self.auth, ClientSecretAuth):
|
|
217
|
+
client_secret = self.auth.secret_provider()
|
|
218
|
+
|
|
219
|
+
result = self.helper.decrypt(
|
|
220
|
+
tenant_id=self.auth.tenant_id,
|
|
221
|
+
client_id=self.auth.client_id,
|
|
222
|
+
certificate_path=self.auth.certificate_path
|
|
223
|
+
if isinstance(self.auth, CertificateAuth)
|
|
224
|
+
else "",
|
|
225
|
+
authorization_mode=self.authorization_mode,
|
|
226
|
+
delegated_user=self.delegated_user,
|
|
227
|
+
source_path=source_path,
|
|
228
|
+
output_path=output_path,
|
|
229
|
+
client_secret=client_secret,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Yield artifact
|
|
233
|
+
artifact = DecryptedFile(
|
|
234
|
+
path=Path(result.output_path),
|
|
235
|
+
filename=output_filename,
|
|
236
|
+
file_format=result.file_format,
|
|
237
|
+
size_bytes=result.size_bytes,
|
|
238
|
+
audit_metadata={
|
|
239
|
+
"source": source_path,
|
|
240
|
+
"authorization_mode": self.authorization_mode,
|
|
241
|
+
"correlation_id": self.correlation_id,
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
yield artifact
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.error(f"Decryption failed: {e}")
|
|
248
|
+
raise
|
|
249
|
+
|
|
250
|
+
finally:
|
|
251
|
+
# Cleanup
|
|
252
|
+
if temp_dir:
|
|
253
|
+
try:
|
|
254
|
+
shutil.rmtree(temp_dir)
|
|
255
|
+
logger.debug(f"Cleaned up temp directory: {temp_dir}")
|
|
256
|
+
except Exception as cleanup_error:
|
|
257
|
+
raise CleanupError(
|
|
258
|
+
f"Failed to remove temporary plaintext directory: {temp_dir}",
|
|
259
|
+
error_code="CleanupFailed",
|
|
260
|
+
) from cleanup_error
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""MIP Wrapper exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MipError(Exception):
|
|
7
|
+
"""Base exception for all MIP Wrapper errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
error_code: str | None = None,
|
|
13
|
+
audit_metadata: dict[str, str] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
self.error_code = error_code
|
|
18
|
+
self.audit_metadata = audit_metadata or {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthenticationError(MipError):
|
|
22
|
+
"""Authentication failed (certificate, token, etc.)."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthorizationError(MipError):
|
|
28
|
+
"""Authorization check failed."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PermissionDeniedError(AuthorizationError):
|
|
34
|
+
"""User lacks required usage right."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnsupportedProtectionError(MipError):
|
|
40
|
+
"""File protection type is not supported."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UnsupportedFileTypeError(MipError):
|
|
46
|
+
"""File format is not supported."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class InvalidConfigurationError(MipError):
|
|
52
|
+
"""Configuration is invalid or incomplete."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NativeRuntimeError(MipError):
|
|
58
|
+
"""Native helper (MipWrapper.Helper) error."""
|
|
59
|
+
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ProtocolError(NativeRuntimeError):
|
|
64
|
+
"""Protocol communication or parsing error."""
|
|
65
|
+
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DecryptionError(NativeRuntimeError):
|
|
70
|
+
"""File decryption failed."""
|
|
71
|
+
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CleanupError(NativeRuntimeError):
|
|
76
|
+
"""Cleanup of temporary files failed."""
|
|
77
|
+
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DestinationError(MipError):
|
|
82
|
+
"""Error uploading to destination."""
|
|
83
|
+
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MissingRuntimeError(MipError):
|
|
88
|
+
"""MipWrapper.Helper or required runtime not available."""
|
|
89
|
+
|
|
90
|
+
pass
|
mip_wrapper/version.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Version information and compatibility checking."""
|
|
2
|
+
|
|
3
|
+
from packaging import version as pkg_version
|
|
4
|
+
|
|
5
|
+
# Package version
|
|
6
|
+
PACKAGE_VERSION = "0.1.0"
|
|
7
|
+
|
|
8
|
+
# Supported protocol version
|
|
9
|
+
PROTOCOL_VERSION = "1.0"
|
|
10
|
+
|
|
11
|
+
# Minimum helper version
|
|
12
|
+
MIN_HELPER_VERSION = "1.0.0"
|
|
13
|
+
|
|
14
|
+
# MIP SDK version we expect
|
|
15
|
+
MIP_SDK_VERSION = "1.18.124"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_helper_version(helper_version: str) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Check helper version compatibility.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
helper_version: Version string from helper
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: If helper version is incompatible
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
helper_v = pkg_version.parse(helper_version)
|
|
30
|
+
min_v = pkg_version.parse(MIN_HELPER_VERSION)
|
|
31
|
+
|
|
32
|
+
if helper_v < min_v:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Helper version {helper_version} is older than minimum "
|
|
35
|
+
f"required {MIN_HELPER_VERSION}. Please rebuild the helper."
|
|
36
|
+
)
|
|
37
|
+
except (ValueError, pkg_version.InvalidVersion) as e:
|
|
38
|
+
raise ValueError(f"Cannot parse helper version '{helper_version}': {e}") from e
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_protocol_version(protocol_version: str) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Check protocol version compatibility.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
protocol_version: Version string from protocol
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If protocol version is incompatible
|
|
50
|
+
"""
|
|
51
|
+
if protocol_version != PROTOCOL_VERSION:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Protocol version mismatch: helper uses {protocol_version}, "
|
|
54
|
+
f"package expects {PROTOCOL_VERSION}"
|
|
55
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mip-wrapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python wrapper for Microsoft Information Protection file decryption
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/firaun2020/mip-wrapper
|
|
7
|
+
Project-URL: Documentation, https://github.com/firaun2020/mip-wrapper/tree/main/docs
|
|
8
|
+
Project-URL: Issues, https://github.com/firaun2020/mip-wrapper/issues
|
|
9
|
+
Keywords: mip,information-protection,azure-rights-management,decryption
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Natural Language :: English
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: packaging>=21.0
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# mip-wrapper
|
|
25
|
+
|
|
26
|
+
Unofficial Python wrapper for inspecting and decrypting files protected by Microsoft Purview Information Protection. The Python package starts a local JSON subprocess bridge to a separately built .NET helper, which calls the Microsoft Information Protection File SDK.
|
|
27
|
+
|
|
28
|
+
## Scope
|
|
29
|
+
|
|
30
|
+
- Windows x64 only
|
|
31
|
+
- Python 3.11 or newer
|
|
32
|
+
- `delegated_reader` is the only supported authorization mode
|
|
33
|
+
- Unattended client-secret authentication via MSAL
|
|
34
|
+
- Local file inspection and temporary decryption
|
|
35
|
+
- No DRM bypass: the delegated user must have the required document usage right
|
|
36
|
+
|
|
37
|
+
The package does not bundle the .NET helper or Microsoft MIP SDK binaries. Build and install the helper separately, and ensure its Microsoft SDK licensing and redistribution terms are satisfied for your environment.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
python -m pip install mip-wrapper
|
|
43
|
+
dotnet publish native/MipWrapper.Helper/MipWrapper.Helper.csproj -c Release -o helper-bin
|
|
44
|
+
$env:MIP_WRAPPER_HELPER_PATH = (Resolve-Path helper-bin/MipWrapper.Helper.exe)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The helper requires the .NET runtime targeted by the project and the native Microsoft MIP SDK runtime available to it. `MIP_WRAPPER_HELPER_PATH` may point to the published helper executable. The source repository's `helper-bin/` directory is a local build output and is not part of the Python distribution.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import os
|
|
53
|
+
from mip_wrapper import MipClient
|
|
54
|
+
from mip_wrapper.auth import ClientSecretAuth
|
|
55
|
+
|
|
56
|
+
auth = ClientSecretAuth(
|
|
57
|
+
tenant_id=os.environ["MIP_TENANT_ID"],
|
|
58
|
+
client_id=os.environ["MIP_CLIENT_ID"],
|
|
59
|
+
secret_provider=lambda: os.environ["MIP_CLIENT_SECRET"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
client = MipClient(
|
|
63
|
+
auth=auth,
|
|
64
|
+
authorization_mode="delegated_reader",
|
|
65
|
+
delegated_user="reader@contoso.com",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
info = client.inspect("protected.xlsx")
|
|
69
|
+
print(info.is_protected, info.usage_rights)
|
|
70
|
+
|
|
71
|
+
with client.decrypted_file("protected.xlsx") as file:
|
|
72
|
+
process(file.path)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The context manager creates a temporary directory, returns the committed plaintext path, and removes the directory on normal exit and exceptions. A cleanup failure raises `CleanupError`. The original protected file is not used as the output path.
|
|
76
|
+
|
|
77
|
+
## Entra and document authorization
|
|
78
|
+
|
|
79
|
+
The app registration must be configured for the Microsoft Information Protection resource and granted the application permission required by the MIP SDK's delegated-reader flow, documented by Microsoft as `Content.DelegatedReader`, with tenant-admin consent. `delegated_reader` is package configuration terminology, not an Entra permission. The delegated user must independently have `Export` or `Owner` usage rights on the protected file; neither the package mode nor the app permission grants document access by itself.
|
|
80
|
+
|
|
81
|
+
The helper acquires app-only tokens with `AcquireTokenForClient`, using the tenant supplied by the request and the MIP SDK challenge resource as `{resource}/.default`. It does not perform an interactive delegated-user sign-in.
|
|
82
|
+
|
|
83
|
+
## Security and operational limits
|
|
84
|
+
|
|
85
|
+
- Do not put client secrets in source code, command-line arguments, logs, or protocol diagnostics. Supply them through a secret provider such as an environment-backed secret store.
|
|
86
|
+
- Protected file contents are written temporarily in plaintext while inside the context manager. Restrict host access and process only authorized content.
|
|
87
|
+
- The helper protocol is local and line-delimited JSON; it is not an authenticated network service.
|
|
88
|
+
- The package does not download, install, or update the helper or Microsoft SDK runtime.
|
|
89
|
+
- Real tenant credentials and protected files are required for integration testing. Unit tests use fake helper processes and do not prove tenant authorization or MIP service connectivity.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```powershell
|
|
94
|
+
python -m pip install -e .
|
|
95
|
+
python -m pytest
|
|
96
|
+
dotnet build native/MipWrapper.Helper/MipWrapper.Helper.csproj -c Release
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
See [`BUILD_LOCALLY.md`](BUILD_LOCALLY.md) for the local helper workflow. See [`docs/distribution-and-licensing.md`](docs/distribution-and-licensing.md) for the unresolved Microsoft SDK distribution and licensing questions. This project is not affiliated with or endorsed by Microsoft.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
The wrapper source is MIT licensed. The Microsoft Information Protection SDK and its native runtime are separately licensed by Microsoft; this repository does not grant rights to redistribute them.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mip_wrapper/__init__.py,sha256=y1GJwzyWAkE2H3ut0fg_hM6J-O-s_Rit2v4dyyTQfdA,1223
|
|
2
|
+
mip_wrapper/artifacts.py,sha256=iYWJYsS0Y4MDnKlHxWIwTv9CCQKJIjohxoATuWLQAB0,2540
|
|
3
|
+
mip_wrapper/auth.py,sha256=cJUWRrN5wpgje7OOmboCvM5eXKHY5snXA4f-Y7y2PXg,3228
|
|
4
|
+
mip_wrapper/client.py,sha256=Lii0d6TFv8c5G9wH2qqCgO9EY4q7RqUAk2U0jxY3Qe0,9261
|
|
5
|
+
mip_wrapper/exceptions.py,sha256=SXhqXTYgqKKMM8XlXr5yMwPXPSXdtOoSHbtFp0I3bNg,1653
|
|
6
|
+
mip_wrapper/version.py,sha256=9udX3ZkFDELKgSbn-rb0f7md6ZEVB8C-kqzaj7JduCg,1502
|
|
7
|
+
mip_wrapper/bridge/__init__.py,sha256=Neq9dKafeP2fJTAwClycJZ_nmcxpcmqq-tUUCRTYktI,346
|
|
8
|
+
mip_wrapper/bridge/client.py,sha256=CIZ97sbrWNObem5zXufjPnOiKM64WWBzIktcScx2S4g,8859
|
|
9
|
+
mip_wrapper/bridge/protocol.py,sha256=5-UEyhrpOKFyHvVSeNCLuXz58bPvU1bRngw5lvPXbos,5037
|
|
10
|
+
mip_wrapper-0.1.0.dist-info/licenses/LICENSE,sha256=BZRGkAz03UQb_T8zLONmY8OLAdiTLYiDiODVbOPci3w,1081
|
|
11
|
+
mip_wrapper-0.1.0.dist-info/METADATA,sha256=-oEntHSmAUzbBL7TcRaXybBAUfTj6Hhm1mwsGcyahBA,5341
|
|
12
|
+
mip_wrapper-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
mip_wrapper-0.1.0.dist-info/top_level.txt,sha256=PQ2If1wExSGG8gNokgUESVTcKOSnniabGTcyohL9Apw,12
|
|
14
|
+
mip_wrapper-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mip-wrapper contributors
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mip_wrapper
|