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.
@@ -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
+ ]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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