aspose-cells-foss 25.12.1__py3-none-any.whl → 26.2.2__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.
- aspose_cells/__init__.py +88 -0
- aspose_cells/auto_filter.py +527 -0
- aspose_cells/cell.py +483 -0
- aspose_cells/cell_value_handler.py +319 -0
- aspose_cells/cells.py +779 -0
- aspose_cells/cfb_handler.py +445 -0
- aspose_cells/cfb_writer.py +659 -0
- aspose_cells/cfb_writer_minimal.py +337 -0
- aspose_cells/comment_xml.py +475 -0
- aspose_cells/conditional_format.py +1185 -0
- aspose_cells/csv_handler.py +690 -0
- aspose_cells/data_validation.py +911 -0
- aspose_cells/document_properties.py +356 -0
- aspose_cells/encryption_crypto.py +247 -0
- aspose_cells/encryption_params.py +138 -0
- aspose_cells/hyperlink.py +372 -0
- aspose_cells/json_handler.py +185 -0
- aspose_cells/markdown_handler.py +583 -0
- aspose_cells/shared_strings.py +101 -0
- aspose_cells/style.py +841 -0
- aspose_cells/workbook.py +499 -0
- aspose_cells/workbook_hash_password.py +68 -0
- aspose_cells/workbook_properties.py +712 -0
- aspose_cells/worksheet.py +570 -0
- aspose_cells/worksheet_properties.py +1239 -0
- aspose_cells/xlsx_encryptor.py +403 -0
- aspose_cells/xml_autofilter_loader.py +195 -0
- aspose_cells/xml_autofilter_saver.py +173 -0
- aspose_cells/xml_conditional_format_loader.py +215 -0
- aspose_cells/xml_conditional_format_saver.py +351 -0
- aspose_cells/xml_datavalidation_loader.py +239 -0
- aspose_cells/xml_datavalidation_saver.py +245 -0
- aspose_cells/xml_hyperlink_handler.py +323 -0
- aspose_cells/xml_loader.py +986 -0
- aspose_cells/xml_properties_loader.py +512 -0
- aspose_cells/xml_properties_saver.py +607 -0
- aspose_cells/xml_saver.py +1306 -0
- aspose_cells_foss-26.2.2.dist-info/METADATA +190 -0
- aspose_cells_foss-26.2.2.dist-info/RECORD +41 -0
- {aspose_cells_foss-25.12.1.dist-info → aspose_cells_foss-26.2.2.dist-info}/WHEEL +1 -1
- aspose_cells_foss-26.2.2.dist-info/top_level.txt +1 -0
- aspose/__init__.py +0 -14
- aspose/cells/__init__.py +0 -31
- aspose/cells/cell.py +0 -350
- aspose/cells/constants.py +0 -44
- aspose/cells/converters/__init__.py +0 -13
- aspose/cells/converters/csv_converter.py +0 -55
- aspose/cells/converters/json_converter.py +0 -46
- aspose/cells/converters/markdown_converter.py +0 -453
- aspose/cells/drawing/__init__.py +0 -17
- aspose/cells/drawing/anchor.py +0 -172
- aspose/cells/drawing/collection.py +0 -233
- aspose/cells/drawing/image.py +0 -338
- aspose/cells/formats.py +0 -80
- aspose/cells/formula/__init__.py +0 -10
- aspose/cells/formula/evaluator.py +0 -360
- aspose/cells/formula/functions.py +0 -433
- aspose/cells/formula/tokenizer.py +0 -340
- aspose/cells/io/__init__.py +0 -27
- aspose/cells/io/csv/__init__.py +0 -8
- aspose/cells/io/csv/reader.py +0 -88
- aspose/cells/io/csv/writer.py +0 -98
- aspose/cells/io/factory.py +0 -138
- aspose/cells/io/interfaces.py +0 -48
- aspose/cells/io/json/__init__.py +0 -8
- aspose/cells/io/json/reader.py +0 -126
- aspose/cells/io/json/writer.py +0 -119
- aspose/cells/io/md/__init__.py +0 -8
- aspose/cells/io/md/reader.py +0 -161
- aspose/cells/io/md/writer.py +0 -334
- aspose/cells/io/models.py +0 -64
- aspose/cells/io/xlsx/__init__.py +0 -9
- aspose/cells/io/xlsx/constants.py +0 -312
- aspose/cells/io/xlsx/image_writer.py +0 -311
- aspose/cells/io/xlsx/reader.py +0 -284
- aspose/cells/io/xlsx/writer.py +0 -931
- aspose/cells/plugins/__init__.py +0 -6
- aspose/cells/plugins/docling_backend/__init__.py +0 -7
- aspose/cells/plugins/docling_backend/backend.py +0 -535
- aspose/cells/plugins/markitdown_plugin/__init__.py +0 -15
- aspose/cells/plugins/markitdown_plugin/plugin.py +0 -128
- aspose/cells/range.py +0 -210
- aspose/cells/style.py +0 -287
- aspose/cells/utils/__init__.py +0 -54
- aspose/cells/utils/coordinates.py +0 -68
- aspose/cells/utils/exceptions.py +0 -43
- aspose/cells/utils/validation.py +0 -102
- aspose/cells/workbook.py +0 -352
- aspose/cells/worksheet.py +0 -670
- aspose_cells_foss-25.12.1.dist-info/METADATA +0 -189
- aspose_cells_foss-25.12.1.dist-info/RECORD +0 -53
- aspose_cells_foss-25.12.1.dist-info/entry_points.txt +0 -2
- aspose_cells_foss-25.12.1.dist-info/top_level.txt +0 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compound File Binary (CFB) Format Handler
|
|
3
|
+
|
|
4
|
+
This module handles reading and writing CFB (also known as OLE) format files
|
|
5
|
+
used for encrypted XLSX packages according to ECMA-376.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import struct
|
|
9
|
+
import base64
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .encryption_params import HashAlgorithm, CipherAlgorithm, EncryptionType
|
|
14
|
+
from .cfb_writer import CFBWriter as CFBWriterImpl
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CFBReader:
|
|
18
|
+
"""
|
|
19
|
+
Reads encrypted XLSX from CFB format.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, file_path):
|
|
23
|
+
"""
|
|
24
|
+
Initialize CFB reader.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file_path: Path to CFB file
|
|
28
|
+
"""
|
|
29
|
+
self.file_path = file_path
|
|
30
|
+
self._fh = open(file_path, 'rb')
|
|
31
|
+
self._load_header()
|
|
32
|
+
self._load_fat()
|
|
33
|
+
self._load_directory()
|
|
34
|
+
|
|
35
|
+
def read_encryption_info(self):
|
|
36
|
+
"""
|
|
37
|
+
Read EncryptionInfo stream from CFB file.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
dict with encryption parameters
|
|
41
|
+
"""
|
|
42
|
+
data = self._read_stream('EncryptionInfo')
|
|
43
|
+
if data is None:
|
|
44
|
+
raise ValueError("EncryptionInfo stream not found in CFB file")
|
|
45
|
+
|
|
46
|
+
# Strip null padding from sector/mini-sector alignment.
|
|
47
|
+
data = data.rstrip(b'\x00')
|
|
48
|
+
|
|
49
|
+
# Read version info
|
|
50
|
+
version_major, version_minor = struct.unpack('<HH', data[0:4])
|
|
51
|
+
flags = struct.unpack('<I', data[4:8])[0]
|
|
52
|
+
|
|
53
|
+
if version_major == 4 and version_minor == 4:
|
|
54
|
+
# Agile Encryption
|
|
55
|
+
return self._parse_agile_encryption_info(data[8:])
|
|
56
|
+
elif version_major in (2, 3, 4) and version_minor == 2:
|
|
57
|
+
# Standard Encryption
|
|
58
|
+
return self._parse_standard_encryption_info(data, flags)
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"Unsupported encryption version: {version_major}.{version_minor}")
|
|
61
|
+
|
|
62
|
+
def _parse_agile_encryption_info(self, xml_data):
|
|
63
|
+
"""
|
|
64
|
+
Parse Agile encryption info from XML.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
xml_data: XML descriptor bytes
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
dict with encryption parameters
|
|
71
|
+
"""
|
|
72
|
+
# Parse XML
|
|
73
|
+
root = ET.fromstring(xml_data.decode('utf-8'))
|
|
74
|
+
|
|
75
|
+
# Define namespace
|
|
76
|
+
ns = {'enc': 'http://schemas.microsoft.com/office/2006/encryption',
|
|
77
|
+
'p': 'http://schemas.microsoft.com/office/2006/keyEncryptor/password'}
|
|
78
|
+
|
|
79
|
+
# Extract keyData
|
|
80
|
+
key_data = root.find('enc:keyData', ns)
|
|
81
|
+
if key_data is None:
|
|
82
|
+
raise ValueError("keyData element not found in Agile encryption info")
|
|
83
|
+
|
|
84
|
+
cipher_algorithm_name = key_data.get('cipherAlgorithm', 'AES')
|
|
85
|
+
key_bits = int(key_data.get('keyBits', '256'))
|
|
86
|
+
hash_algorithm_name = key_data.get('hashAlgorithm', 'SHA512')
|
|
87
|
+
salt_value = base64.b64decode(key_data.get('saltValue', ''))
|
|
88
|
+
|
|
89
|
+
# Extract encryptedKey from keyEncryptors
|
|
90
|
+
encrypted_key_elem = root.find('.//p:encryptedKey', ns)
|
|
91
|
+
if encrypted_key_elem is None:
|
|
92
|
+
raise ValueError("encryptedKey element not found")
|
|
93
|
+
|
|
94
|
+
spin_count = int(encrypted_key_elem.get('spinCount', '100000'))
|
|
95
|
+
key_salt = base64.b64decode(encrypted_key_elem.get('saltValue', ''))
|
|
96
|
+
encrypted_verifier = base64.b64decode(encrypted_key_elem.get('encryptedVerifierHashInput', ''))
|
|
97
|
+
encrypted_verifier_hash = base64.b64decode(encrypted_key_elem.get('encryptedVerifierHashValue', ''))
|
|
98
|
+
encrypted_key_value = base64.b64decode(encrypted_key_elem.get('encryptedKeyValue', ''))
|
|
99
|
+
|
|
100
|
+
data_integrity = root.find('enc:dataIntegrity', ns)
|
|
101
|
+
encrypted_hmac_key = b''
|
|
102
|
+
encrypted_hmac_value = b''
|
|
103
|
+
if data_integrity is not None:
|
|
104
|
+
encrypted_hmac_key = base64.b64decode(data_integrity.get('encryptedHmacKey', '') or b'')
|
|
105
|
+
encrypted_hmac_value = base64.b64decode(data_integrity.get('encryptedHmacValue', '') or b'')
|
|
106
|
+
|
|
107
|
+
# Map algorithm names to enums
|
|
108
|
+
cipher_algorithm = self._map_cipher_algorithm(cipher_algorithm_name, key_bits)
|
|
109
|
+
hash_algorithm = self._map_hash_algorithm(hash_algorithm_name)
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
'type': EncryptionType.AGILE,
|
|
113
|
+
'cipher_algorithm': cipher_algorithm,
|
|
114
|
+
'hash_algorithm': hash_algorithm,
|
|
115
|
+
'spin_count': spin_count,
|
|
116
|
+
'salt': key_salt,
|
|
117
|
+
'encrypted_verifier': encrypted_verifier,
|
|
118
|
+
'encrypted_verifier_hash': encrypted_verifier_hash,
|
|
119
|
+
'encrypted_key_value': encrypted_key_value,
|
|
120
|
+
'encrypted_hmac_key': encrypted_hmac_key,
|
|
121
|
+
'encrypted_hmac_value': encrypted_hmac_value,
|
|
122
|
+
'package_salt': salt_value
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def _parse_standard_encryption_info(self, data, flags):
|
|
126
|
+
"""
|
|
127
|
+
Parse Standard encryption info from binary data.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
data: Full EncryptionInfo data
|
|
131
|
+
flags: Flags value
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
dict with encryption parameters
|
|
135
|
+
"""
|
|
136
|
+
# This is a simplified parser for Standard encryption
|
|
137
|
+
# Full implementation would need to parse the complete binary structure
|
|
138
|
+
raise NotImplementedError("Standard encryption is not yet supported. "
|
|
139
|
+
"Please use Agile encryption (Office 2010+)")
|
|
140
|
+
|
|
141
|
+
def _map_cipher_algorithm(self, name, key_bits):
|
|
142
|
+
"""Map cipher algorithm name and key bits to enum."""
|
|
143
|
+
if name == 'AES':
|
|
144
|
+
if key_bits == 128:
|
|
145
|
+
return CipherAlgorithm.AES_128
|
|
146
|
+
elif key_bits == 192:
|
|
147
|
+
return CipherAlgorithm.AES_192
|
|
148
|
+
elif key_bits == 256:
|
|
149
|
+
return CipherAlgorithm.AES_256
|
|
150
|
+
raise ValueError(f"Unsupported cipher algorithm: {name}-{key_bits}")
|
|
151
|
+
|
|
152
|
+
def _map_hash_algorithm(self, name):
|
|
153
|
+
"""Map hash algorithm name to enum."""
|
|
154
|
+
name_upper = name.upper().replace('-', '')
|
|
155
|
+
if name_upper == 'SHA1':
|
|
156
|
+
return HashAlgorithm.SHA1
|
|
157
|
+
elif name_upper == 'SHA256':
|
|
158
|
+
return HashAlgorithm.SHA256
|
|
159
|
+
elif name_upper == 'SHA384':
|
|
160
|
+
return HashAlgorithm.SHA384
|
|
161
|
+
elif name_upper == 'SHA512':
|
|
162
|
+
return HashAlgorithm.SHA512
|
|
163
|
+
raise ValueError(f"Unsupported hash algorithm: {name}")
|
|
164
|
+
|
|
165
|
+
def read_encrypted_package(self):
|
|
166
|
+
"""
|
|
167
|
+
Read EncryptedPackage stream from CFB file.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
bytes: Encrypted package data
|
|
171
|
+
"""
|
|
172
|
+
data = self._read_stream_raw('EncryptedPackage')
|
|
173
|
+
if data is None:
|
|
174
|
+
raise ValueError("EncryptedPackage stream not found in CFB file")
|
|
175
|
+
|
|
176
|
+
# Read package size (first 8 bytes as uint64)
|
|
177
|
+
size_bytes = data[:8]
|
|
178
|
+
package_size = struct.unpack('<Q', size_bytes)[0]
|
|
179
|
+
encrypted_payload = data[8:]
|
|
180
|
+
return package_size, encrypted_payload, data
|
|
181
|
+
|
|
182
|
+
def close(self):
|
|
183
|
+
"""Close the CFB file."""
|
|
184
|
+
if self._fh:
|
|
185
|
+
self._fh.close()
|
|
186
|
+
|
|
187
|
+
def __enter__(self):
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
191
|
+
self.close()
|
|
192
|
+
|
|
193
|
+
def _load_header(self):
|
|
194
|
+
self._fh.seek(0)
|
|
195
|
+
header = self._fh.read(512)
|
|
196
|
+
sig = struct.unpack('<Q', header[0:8])[0]
|
|
197
|
+
if sig != 0xE11AB1A1E011CFD0:
|
|
198
|
+
raise ValueError("Invalid CFB signature")
|
|
199
|
+
|
|
200
|
+
self.sector_shift = struct.unpack('<H', header[30:32])[0]
|
|
201
|
+
self.sector_size = 1 << self.sector_shift
|
|
202
|
+
self.mini_sector_size = 1 << struct.unpack('<H', header[32:34])[0]
|
|
203
|
+
self.num_fat_sectors = struct.unpack('<I', header[44:48])[0]
|
|
204
|
+
self.dir_start = struct.unpack('<I', header[48:52])[0]
|
|
205
|
+
self.mini_stream_cutoff = struct.unpack('<I', header[56:60])[0]
|
|
206
|
+
self.mini_fat_start = struct.unpack('<I', header[60:64])[0]
|
|
207
|
+
self.num_mini_fat_sectors = struct.unpack('<I', header[64:68])[0]
|
|
208
|
+
|
|
209
|
+
# DIFAT entries (first 109)
|
|
210
|
+
self.difat = []
|
|
211
|
+
for i in range(109):
|
|
212
|
+
entry = struct.unpack('<I', header[76 + i * 4: 80 + i * 4])[0]
|
|
213
|
+
if entry != 0xFFFFFFFF:
|
|
214
|
+
self.difat.append(entry)
|
|
215
|
+
|
|
216
|
+
def _read_sector(self, sector_index):
|
|
217
|
+
self._fh.seek(512 + sector_index * self.sector_size)
|
|
218
|
+
return self._fh.read(self.sector_size)
|
|
219
|
+
|
|
220
|
+
def _load_fat(self):
|
|
221
|
+
fat_entries = []
|
|
222
|
+
for i in range(self.num_fat_sectors):
|
|
223
|
+
sector = self.difat[i]
|
|
224
|
+
data = self._read_sector(sector)
|
|
225
|
+
for j in range(0, len(data), 4):
|
|
226
|
+
fat_entries.append(struct.unpack('<I', data[j:j + 4])[0])
|
|
227
|
+
self.fat = fat_entries
|
|
228
|
+
|
|
229
|
+
def _load_directory(self):
|
|
230
|
+
dir_data = self._read_stream_by_chain(self.dir_start, is_mini=False)
|
|
231
|
+
entries = []
|
|
232
|
+
for i in range(0, len(dir_data), 128):
|
|
233
|
+
entry = dir_data[i:i + 128]
|
|
234
|
+
if len(entry) < 128:
|
|
235
|
+
break
|
|
236
|
+
name_len = struct.unpack('<H', entry[64:66])[0]
|
|
237
|
+
name_bytes = entry[0:name_len - 2] if name_len >= 2 else b''
|
|
238
|
+
name = name_bytes.decode('utf-16le', errors='ignore')
|
|
239
|
+
obj_type = entry[66]
|
|
240
|
+
color = entry[67]
|
|
241
|
+
left = struct.unpack('<I', entry[68:72])[0]
|
|
242
|
+
right = struct.unpack('<I', entry[72:76])[0]
|
|
243
|
+
child = struct.unpack('<I', entry[76:80])[0]
|
|
244
|
+
starting_sector = struct.unpack('<I', entry[116:120])[0]
|
|
245
|
+
size = struct.unpack('<Q', entry[120:128])[0]
|
|
246
|
+
entries.append({
|
|
247
|
+
'name': name,
|
|
248
|
+
'type': obj_type,
|
|
249
|
+
'color': color,
|
|
250
|
+
'left': left,
|
|
251
|
+
'right': right,
|
|
252
|
+
'child': child,
|
|
253
|
+
'start': starting_sector,
|
|
254
|
+
'size': size,
|
|
255
|
+
})
|
|
256
|
+
self.dir_entries = entries
|
|
257
|
+
|
|
258
|
+
# Build name->entry index mapping with path traversal
|
|
259
|
+
self.name_map = {}
|
|
260
|
+
def visit(index, parent_path):
|
|
261
|
+
if index == 0xFFFFFFFF or index >= len(self.dir_entries):
|
|
262
|
+
return
|
|
263
|
+
entry = self.dir_entries[index]
|
|
264
|
+
visit(entry['left'], parent_path)
|
|
265
|
+
name = entry['name']
|
|
266
|
+
path = name if not parent_path else parent_path + '/' + name
|
|
267
|
+
self.name_map[path] = index
|
|
268
|
+
if entry['type'] in (1, 5): # storage or root
|
|
269
|
+
visit(entry['child'], path if entry['type'] == 1 else "")
|
|
270
|
+
visit(entry['right'], parent_path)
|
|
271
|
+
visit(0, "")
|
|
272
|
+
|
|
273
|
+
def _read_stream(self, name):
|
|
274
|
+
index = self.name_map.get(name)
|
|
275
|
+
if index is None:
|
|
276
|
+
return None
|
|
277
|
+
entry = self.dir_entries[index]
|
|
278
|
+
size = entry['size']
|
|
279
|
+
if size == 0:
|
|
280
|
+
return b''
|
|
281
|
+
if size < self.mini_stream_cutoff and entry['type'] == 2:
|
|
282
|
+
return self._read_mini_stream(entry['start'], size)
|
|
283
|
+
return self._read_stream_by_chain(entry['start'], is_mini=False, size=size)
|
|
284
|
+
|
|
285
|
+
def _read_stream_raw(self, name):
|
|
286
|
+
index = self.name_map.get(name)
|
|
287
|
+
if index is None:
|
|
288
|
+
return None
|
|
289
|
+
entry = self.dir_entries[index]
|
|
290
|
+
size = entry['size']
|
|
291
|
+
if size == 0:
|
|
292
|
+
return b''
|
|
293
|
+
if size < self.mini_stream_cutoff and entry['type'] == 2:
|
|
294
|
+
return self._read_mini_stream(entry['start'], size)
|
|
295
|
+
data = self._read_stream_by_chain(entry['start'], is_mini=False, size=None)
|
|
296
|
+
return data[:size]
|
|
297
|
+
|
|
298
|
+
def _read_stream_by_chain(self, start_sector, is_mini=False, size=None):
|
|
299
|
+
data = bytearray()
|
|
300
|
+
sector = start_sector
|
|
301
|
+
while sector not in (0xFFFFFFFE, 0xFFFFFFFF):
|
|
302
|
+
data.extend(self._read_sector(sector))
|
|
303
|
+
sector = self.fat[sector]
|
|
304
|
+
if size is not None:
|
|
305
|
+
return bytes(data[:size])
|
|
306
|
+
return bytes(data)
|
|
307
|
+
|
|
308
|
+
def _load_minifat(self):
|
|
309
|
+
if self.num_mini_fat_sectors == 0:
|
|
310
|
+
self.mini_fat = []
|
|
311
|
+
return
|
|
312
|
+
data = bytearray()
|
|
313
|
+
sector = self.mini_fat_start
|
|
314
|
+
for _ in range(self.num_mini_fat_sectors):
|
|
315
|
+
data.extend(self._read_sector(sector))
|
|
316
|
+
sector = self.fat[sector]
|
|
317
|
+
self.mini_fat = []
|
|
318
|
+
for i in range(0, len(data), 4):
|
|
319
|
+
self.mini_fat.append(struct.unpack('<I', data[i:i + 4])[0])
|
|
320
|
+
|
|
321
|
+
def _read_mini_stream(self, start_mini_sector, size):
|
|
322
|
+
if not hasattr(self, 'mini_fat'):
|
|
323
|
+
self._load_minifat()
|
|
324
|
+
# root entry is 0
|
|
325
|
+
root = self.dir_entries[0]
|
|
326
|
+
mini_stream = self._read_stream_by_chain(root['start'], is_mini=False, size=root['size'])
|
|
327
|
+
data = bytearray()
|
|
328
|
+
mini_sector = start_mini_sector
|
|
329
|
+
while mini_sector not in (0xFFFFFFFE, 0xFFFFFFFF):
|
|
330
|
+
offset = mini_sector * self.mini_sector_size
|
|
331
|
+
data.extend(mini_stream[offset:offset + self.mini_sector_size])
|
|
332
|
+
mini_sector = self.mini_fat[mini_sector]
|
|
333
|
+
return bytes(data[:size])
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class CFBWriter:
|
|
337
|
+
"""
|
|
338
|
+
Writes encrypted XLSX to CFB format.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
def __init__(self):
|
|
342
|
+
"""Initialize CFB writer."""
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
def write(self, file_path, encryption_info_xml, encrypted_package, package_size):
|
|
346
|
+
"""
|
|
347
|
+
Write CFB file with EncryptionInfo and EncryptedPackage streams.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
file_path: Output file path
|
|
351
|
+
encryption_info_xml: EncryptionInfo XML bytes
|
|
352
|
+
encrypted_package: Encrypted package bytes
|
|
353
|
+
package_size: Original (unencrypted) package size in bytes
|
|
354
|
+
"""
|
|
355
|
+
# Prepare stream data
|
|
356
|
+
# EncryptionInfo: Version + Flags + XML
|
|
357
|
+
version_info = struct.pack('<HH', 4, 4) # Major=4, Minor=4
|
|
358
|
+
flags = struct.pack('<I', 0x40)
|
|
359
|
+
encryption_info_data = version_info + flags + encryption_info_xml
|
|
360
|
+
|
|
361
|
+
# EncryptedPackage: Size + Data
|
|
362
|
+
package_size_bytes = struct.pack('<Q', package_size)
|
|
363
|
+
encrypted_package_data = package_size_bytes + encrypted_package
|
|
364
|
+
|
|
365
|
+
# Create CFB file using MS-CFB compliant writer
|
|
366
|
+
# Use 512-byte sectors (version 3) for maximum compatibility with olefile
|
|
367
|
+
writer = CFBWriterImpl(sector_size=512)
|
|
368
|
+
# Allow CFB writer to place small streams in the mini stream.
|
|
369
|
+
writer.add_stream('EncryptionInfo', encryption_info_data)
|
|
370
|
+
writer.add_stream('EncryptedPackage', encrypted_package_data)
|
|
371
|
+
for name, data in _build_dataspaces_streams().items():
|
|
372
|
+
writer.add_stream(name, data)
|
|
373
|
+
writer.write(file_path)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _build_dataspaces_streams():
|
|
377
|
+
"""
|
|
378
|
+
Build DataSpaces streams required by Office for Agile encryption.
|
|
379
|
+
"""
|
|
380
|
+
def _unicode_lp_p4(text):
|
|
381
|
+
data = text.encode('utf-16le')
|
|
382
|
+
return struct.pack('<I', len(data)) + data
|
|
383
|
+
|
|
384
|
+
def _pad4(data):
|
|
385
|
+
pad = (-len(data)) % 4
|
|
386
|
+
if pad:
|
|
387
|
+
return data + (b'\x00' * pad)
|
|
388
|
+
return data
|
|
389
|
+
|
|
390
|
+
# Version stream
|
|
391
|
+
version_name = "Microsoft.Container.DataSpaces"
|
|
392
|
+
version_data = _unicode_lp_p4(version_name) + struct.pack('<III', 1, 1, 1)
|
|
393
|
+
|
|
394
|
+
# DataSpaceMap stream
|
|
395
|
+
component_name = "EncryptedPackage"
|
|
396
|
+
dataspace_name = "StrongEncryptionDataSpace"
|
|
397
|
+
entry = struct.pack('<I', 1) # cRefComponents
|
|
398
|
+
entry += struct.pack('<I', 0) # ReferenceComponentType = Stream (0)
|
|
399
|
+
entry += _unicode_lp_p4(component_name)
|
|
400
|
+
entry += _unicode_lp_p4(dataspace_name)
|
|
401
|
+
entry = _pad4(entry)
|
|
402
|
+
# Excel sets entry length to 104 even though entry payload is 100 bytes.
|
|
403
|
+
# Match that for compatibility.
|
|
404
|
+
data_space_map = struct.pack('<II', 8, 1) + struct.pack('<I', len(entry) + 4) + entry
|
|
405
|
+
|
|
406
|
+
# DataSpaceInfo stream
|
|
407
|
+
transform_name = "StrongEncryptionTransform"
|
|
408
|
+
ds_info = struct.pack('<II', 8, 1) + _pad4(_unicode_lp_p4(transform_name))
|
|
409
|
+
|
|
410
|
+
# TransformInfo Primary stream
|
|
411
|
+
transform_id = "{FF9A3F03-56EF-4613-BDD5-5A41C1D07246}"
|
|
412
|
+
transform_type = "Microsoft.Container.EncryptionTransform"
|
|
413
|
+
primary = struct.pack('<II', 88, 1)
|
|
414
|
+
primary += _unicode_lp_p4(transform_id)
|
|
415
|
+
primary += _unicode_lp_p4(transform_type)
|
|
416
|
+
primary += bytes.fromhex(
|
|
417
|
+
"000001000000010000000100000000000000000000000000000004000000"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
"\x06DataSpaces/Version": version_data,
|
|
422
|
+
"\x06DataSpaces/DataSpaceMap": data_space_map,
|
|
423
|
+
"\x06DataSpaces/DataSpaceInfo/StrongEncryptionDataSpace": ds_info,
|
|
424
|
+
"\x06DataSpaces/TransformInfo/StrongEncryptionTransform/\x06Primary": primary,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def is_encrypted_file(file_path):
|
|
429
|
+
"""
|
|
430
|
+
Check if a file is an encrypted XLSX (CFB format).
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
file_path: Path to file
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
bool: True if file is encrypted (CFB format)
|
|
437
|
+
"""
|
|
438
|
+
try:
|
|
439
|
+
with open(file_path, 'rb') as f:
|
|
440
|
+
# Check for CFB signature (first 8 bytes)
|
|
441
|
+
header = f.read(8)
|
|
442
|
+
# CFB signature: D0 CF 11 E0 A1 B1 1A E1
|
|
443
|
+
return header == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'
|
|
444
|
+
except Exception:
|
|
445
|
+
return False
|