ensec-cli 1.0.11__tar.gz → 2.1.0__tar.gz

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.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ensec-cli
3
- Version: 1.0.11
3
+ Version: 2.1.0
4
4
  Summary: EncryptSecureDEC Open Source CLI
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  Requires-Dist: pycryptodome
9
9
  Requires-Dist: cryptography
10
+ Requires-Dist: reedsolo
10
11
  Dynamic: license-file
11
12
 
12
13
  # ENSEC-CLI
@@ -0,0 +1 @@
1
+ __version__ = "2.1.0"
@@ -0,0 +1,275 @@
1
+ import os
2
+ import json
3
+ import struct
4
+ import configparser
5
+ import hashlib
6
+
7
+
8
+ # ======================================================
9
+ # SHA256
10
+ # ======================================================
11
+ def calc_sha256(data: bytes) -> str:
12
+ return hashlib.sha256(data).hexdigest()
13
+
14
+
15
+ # ======================================================
16
+ # 設定ファイルパス生成
17
+ # ======================================================
18
+ from pathlib import Path
19
+ import os
20
+
21
+ APP_NAME = "ENSEC"
22
+
23
+ def _default_db_path() -> Path:
24
+ if os.name == "nt":
25
+ home = Path.home()
26
+ base = home / ".local" / "share" / APP_NAME
27
+ else:
28
+ xdg_data_home = Path(
29
+ os.getenv(
30
+ "XDG_DATA_HOME",
31
+ Path.home() / ".local" / "share"
32
+ )
33
+ )
34
+ base = xdg_data_home / APP_NAME
35
+
36
+ base.mkdir(parents=True, exist_ok=True)
37
+
38
+ return base / "split_config.ini"
39
+
40
+ def init_split_config():
41
+ """
42
+ split_config.ini が存在しない場合だけ、
43
+ デフォルト(基本無効)の設定ファイルを生成する。
44
+ 既存ファイルがある場合は絶対に上書きしない。
45
+ """
46
+ config_path = _default_db_path()
47
+
48
+ if os.path.exists(config_path):
49
+ return
50
+
51
+ config = configparser.ConfigParser()
52
+
53
+ config["Split"] = {
54
+ "enabled": "false", # 基本無効
55
+ "mode": "half",
56
+ "parts": "2",
57
+ "delmode": "false"
58
+ }
59
+
60
+ with open(config_path, "w", encoding="utf-8") as f:
61
+ config.write(f)
62
+
63
+
64
+
65
+ def _load_split_settings() -> dict:
66
+
67
+ settings = {
68
+ "mode": "count",
69
+ "parts": 4
70
+ }
71
+
72
+ if settings["parts"] < 1:
73
+ settings["parts"] = 1
74
+
75
+ return settings
76
+
77
+
78
+ # ======================================================
79
+ # chunk_size 自動判定
80
+ # ======================================================
81
+ def auto_chunk_size(file_size):
82
+ MB = 1024 * 1024
83
+ if file_size < 10 * MB:
84
+ return None
85
+ elif file_size < 100 * MB:
86
+ return 5 * MB
87
+ elif file_size < 500 * MB:
88
+ return 10 * MB
89
+ elif file_size < 1024 * MB:
90
+ return 20 * MB
91
+ else:
92
+ return 50 * MB
93
+
94
+
95
+ HEADER_SIZE_LEN = 4
96
+
97
+
98
+ # ======================================================
99
+ # ヘッダー付きパート書き込み(署名付き)
100
+ # ======================================================
101
+ def _write_part(path, part_index, total_parts, original_name, data):
102
+ header = {
103
+ "original_name": original_name,
104
+ "part_index": part_index,
105
+ "total_parts": total_parts,
106
+ "chunk_size": len(data),
107
+ "sha256": calc_sha256(data)
108
+ }
109
+
110
+ header_raw = json.dumps(header).encode("utf-8")
111
+ header_size = struct.pack(">I", len(header_raw))
112
+
113
+ with open(path, "wb") as f:
114
+ f.write(header_size)
115
+ f.write(header_raw)
116
+ f.write(data)
117
+
118
+
119
+ # ======================================================
120
+ # 分割(完全自動制御)
121
+ # ======================================================
122
+ def split_file_with_header(file_path):
123
+ sett = _load_split_settings()
124
+
125
+ #if not enable:
126
+ # return None
127
+
128
+ mode = sett["mode"]
129
+ parts = sett["parts"]
130
+
131
+ total_size = os.path.getsize(file_path)
132
+ original_name = os.path.basename(file_path)
133
+
134
+ if mode == "half":
135
+ total_parts = 2
136
+ half = total_size // 2
137
+ chunk_sizes = [half, total_size - half]
138
+
139
+ else:
140
+ total_parts = parts
141
+ base = total_size // parts
142
+ remainder = total_size % parts
143
+ chunk_sizes = [base] * parts
144
+ chunk_sizes[-1] += remainder
145
+
146
+ out_paths = []
147
+
148
+ with open(file_path, "rb") as fin:
149
+ for i in range(total_parts):
150
+ data = fin.read(chunk_sizes[i])
151
+ part_path = f"{file_path}{i}"
152
+
153
+ _write_part(
154
+ path=part_path,
155
+ part_index=i,
156
+ total_parts=total_parts,
157
+ original_name=original_name,
158
+ data=data
159
+ )
160
+
161
+ out_paths.append(part_path)
162
+
163
+ return out_paths
164
+
165
+
166
+ def is_delmode_enabled() -> bool:
167
+ config_path = _default_db_path()
168
+
169
+ if not os.path.exists(config_path):
170
+ return False
171
+
172
+ config = configparser.ConfigParser()
173
+ config.read(config_path, encoding="utf-8")
174
+
175
+ return config.getboolean("Split", "delmode", fallback=False)
176
+
177
+
178
+ # ======================================================
179
+ # 拡張子取得
180
+ # ======================================================
181
+ def get_file_extension(file_path: str) -> str:
182
+ if not isinstance(file_path, str) or not file_path.strip():
183
+ raise ValueError("ファイルパスは非空の文字列で指定してください。")
184
+ _, ext = os.path.splitext(file_path)
185
+ return ext
186
+
187
+
188
+ # ======================================================
189
+ # 結合(part0 → 自動判定)
190
+ # ======================================================
191
+ import re
192
+ def merge_from_part0(part0_path, output_path=None):
193
+ part0_path = os.path.abspath(part0_path)
194
+
195
+ folder = os.path.dirname(part0_path)
196
+ base = os.path.basename(part0_path)
197
+ prefix = base[:-1]
198
+
199
+ pattern = re.compile(rf"^{re.escape(prefix)}\d+$")
200
+
201
+ parts = [
202
+ os.path.join(folder, fn)
203
+ for fn in os.listdir(folder)
204
+ if pattern.match(fn)
205
+ ]
206
+
207
+ return merge_files_with_header(parts, output_path)
208
+
209
+
210
+ # ======================================================
211
+ # 完全検証付き結合処理(署名付き)
212
+ # ======================================================
213
+ def merge_files_with_header(parts, output_path=None):
214
+ part_info = []
215
+
216
+ for path in parts:
217
+ try:
218
+ with open(path, "rb") as f:
219
+ raw = f.read(4)
220
+ if len(raw) < 4:
221
+ continue
222
+
223
+ header_size = struct.unpack(">I", raw)[0]
224
+ header_raw = f.read(header_size)
225
+ header = json.loads(header_raw.decode("utf-8"))
226
+
227
+ required = ("original_name", "part_index", "total_parts", "sha256")
228
+ if not all(k in header for k in required):
229
+ raise ValueError(f"{path} のヘッダーが不完全です")
230
+
231
+ part_info.append((header["part_index"], path, header))
232
+
233
+ except Exception as e:
234
+ raise ValueError(f"{path} の読み取り失敗: {e}")
235
+
236
+ if not part_info:
237
+ raise ValueError("適切なパートが見つかりません")
238
+
239
+ part_info.sort(key=lambda x: x[0])
240
+
241
+ names = {h["original_name"] for _,_,h in part_info}
242
+ if len(names) != 1:
243
+ raise ValueError("original_name が一致しません → 別ファイルのパートが混入")
244
+
245
+ original_name = part_info[0][2]["original_name"]
246
+
247
+ if ".." in original_name or "/" in original_name or "\\" in original_name:
248
+ raise ValueError("original_name に危険な文字列があります")
249
+
250
+ total_parts = part_info[0][2]["total_parts"]
251
+ if total_parts != len(part_info):
252
+ raise ValueError("total_parts と実パート数が一致しません")
253
+
254
+ indexes = [idx for idx,_,_ in part_info]
255
+ if sorted(indexes) != list(range(total_parts)):
256
+ raise ValueError("part_index が不正です(重複・欠落・範囲外)")
257
+
258
+ if output_path is None:
259
+ folder = os.path.dirname(parts[0])
260
+ output_path = os.path.join(folder, original_name)
261
+
262
+ with open(output_path, "wb") as out:
263
+ for idx, part_path, header in part_info:
264
+ with open(part_path, "rb") as f:
265
+ hsz = struct.unpack(">I", f.read(4))[0]
266
+ f.read(hsz)
267
+ payload = f.read()
268
+
269
+ # --- 署名検証 ---
270
+ if calc_sha256(payload) != header["sha256"]:
271
+ raise ValueError(f"{part_path} は改ざんされています(ハッシュ不一致)")
272
+
273
+ out.write(payload)
274
+
275
+ return output_path
@@ -17,11 +17,15 @@ from . import rsa_encryptor
17
17
  from pathlib import Path
18
18
  from .conf_load import load_config
19
19
  from . import __version__ as VERSION
20
-
20
+ from .ecc import *
21
+ from .bin_ext import *
21
22
  # OKならそのまま続行
22
23
 
24
+ is_split_enabled=False
23
25
  conf=load_config()
24
26
 
27
+ ecc = False
28
+
25
29
  BLOCKCHAIN_HEADER = b'BLOCKCHAIN_DATA_START\n'
26
30
  def _format_bytes(num: int) -> str:
27
31
  units = ["B", "KB", "MB", "GB", "TB", "PB"]
@@ -164,26 +168,58 @@ def cli_encrypt(file_path, password, memo):
164
168
  username = getpass.getuser()
165
169
 
166
170
  try:
167
- with lzma.open(file_path + ".vdec", 'rb') as f:
168
- data = f.read()
171
+ with open(file_path + ".vdec", 'rb') as f:
172
+ raw_data = f.read()
173
+ if ecc==True:
174
+ try:
175
+ data = recover_data(raw_data)
176
+ if data is None:
177
+ data = lzma.decompress(raw_data)
178
+ except(ValueError, lzma.LZMAError):
179
+ data = lzma.decompress(raw_data)
180
+ else:
181
+ data=lzma.decompress(raw_data)
169
182
  split_index = data.index(BLOCKCHAIN_HEADER)
170
183
  chain_json = data[split_index + len(BLOCKCHAIN_HEADER):].decode('utf-8')
171
184
  blockchain = Blockchain.from_json(chain_json)
172
- except:
185
+ except Exception as e:
186
+ if os.path.exists(file_path + ".vdec") :
187
+ print("警告:既存のブロックチェーンデータの読み込みに失敗しました。\n新規にブロックチェーンを作成します。")
173
188
  blockchain = Blockchain()
189
+
174
190
  block = Block(file_hash, blockchain.chain[-1].hash if blockchain.chain else "0", "Encrypt", file_hash, username, memo)
175
191
  blockchain.add_block(block)
176
192
 
177
193
  encrypted_data = salt + nonce + ciphertext + tag
178
194
  blockchain_data = BLOCKCHAIN_HEADER + blockchain.to_json().encode('utf-8')
179
195
 
196
+ combined_data = encrypted_data + blockchain_data
197
+ # 先にLZMA圧縮
198
+ compressed_data = lzma.compress(combined_data)
199
+
200
+ if ecc==True and not is_split_enabled:
201
+ ecc_data = add_ecc(compressed_data)
202
+ else:
203
+ ecc_data = compressed_data
204
+
180
205
  out_path = file_path + ".vdec"
181
- with lzma.open(out_path, 'wb') as f:
182
- f.write(encrypted_data)
183
- f.write(blockchain_data)
206
+ with open(out_path, 'wb') as f:
207
+ f.write(ecc_data)
208
+
209
+ if is_split_enabled:
210
+ a = split_file_with_header(out_path)
211
+ if a is not None:
212
+ display_path = a[0]
213
+ else:
214
+ display_path = out_path
215
+ else:
216
+ display_path = out_path
217
+
218
+ if is_split_enabled:
219
+ delete_pre_file(out_path)
184
220
  if deletemode["mode"] == True:
185
221
  delete_pre_file(file_path)
186
- print(f"✅ Encryption completed: {out_path}")
222
+ print(f"✅ Encryption completed: {display_path}")
187
223
 
188
224
  def cli_decrypt(file_path, password, memo):
189
225
  if file_path.endswith(".wav"):
@@ -191,8 +227,41 @@ def cli_decrypt(file_path, password, memo):
191
227
  wav_bytes = f.read()
192
228
  data = wavencode.wav_bytes_to_binary(wav_bytes)
193
229
  else:
194
- with lzma.open(file_path, 'rb') as f:
195
- data = f.read()
230
+ try:
231
+ with open(file_path, 'rb') as f:
232
+ data = f.read()
233
+ if get_file_extension(file_path)!=".vdec0":
234
+ # 分割ファイルでない場合はECC復元とLZMA展開を試みる。失敗したら(分割ファイルでない場合)LZMA展開のみ試みる。
235
+ if ecc==True:
236
+ data = recover_data(data) # ECC復元とLZMA展開を試みる(成功すればdataが更新される)
237
+
238
+ if data is None:
239
+ raise ValueError("データの復元に失敗")
240
+ else:
241
+ data = lzma.decompress(data)
242
+ elif get_file_extension(file_path)==".vdec0":
243
+ try:
244
+ catc = merge_from_part0(file_path)
245
+ if catc!=None:
246
+ file_path=catc
247
+ with open(file_path, 'rb') as f:
248
+ data = f.read()
249
+ data = lzma.decompress(data)
250
+ except Exception as e:
251
+ print(f"エラー:次のエラーが発生しました:\n{e}")
252
+ return None
253
+ else:
254
+ data = lzma.decompress(data)
255
+ except ValueError as e:
256
+ print("エラー:ファイルの読み込みに失敗しました")
257
+ print("ECCでも復元できないくらいにファイルが破損している可能性があります")
258
+ return
259
+ except EOFError:
260
+ print("エラー:圧縮データが途中で途切れています\n(ファイルが破損している可能性があります)\n分割データの場合必要なパーツが足りません")
261
+ return
262
+ except lzma.LZMAError:
263
+ print("エラー:LZMA展開に失敗しました")
264
+ return
196
265
 
197
266
  split_index = data.index(BLOCKCHAIN_HEADER)
198
267
  crypto_data = data[:split_index]
@@ -225,12 +294,24 @@ def cli_decrypt(file_path, password, memo):
225
294
  file_hash = hashlib.sha256(ciphertext).hexdigest()
226
295
  block = Block(file_hash, blockchain.chain[-1].hash if blockchain.chain else "0", "Decrypt", file_hash, username, memo)
227
296
  blockchain.add_block(block)
297
+
298
+ # 暗号バイト列
299
+ encrypted_data = salt + nonce + ciphertext + tag
300
+ blockchain_data = BLOCKCHAIN_HEADER + blockchain.to_json().encode('utf-8')
301
+
302
+ combined_data = encrypted_data + blockchain_data
303
+
304
+ compressed_data = lzma.compress(combined_data)
305
+
306
+ if ecc==True:
307
+ ecc_data = add_ecc(compressed_data)
308
+ else:
309
+ ecc_data = compressed_data
228
310
 
229
311
  if not file_path.endswith(".wav"):
230
- with lzma.open(file_path, 'wb') as f:
231
- f.write(salt + nonce + ciphertext + tag)
232
- f.write(BLOCKCHAIN_HEADER)
233
- f.write(blockchain.to_json().encode('utf-8'))
312
+ with open(file_path, 'wb') as f:
313
+ f.write(ecc_data)
314
+
234
315
 
235
316
  print(f"✅ Decryption completed: {output_file}")
236
317
 
@@ -239,8 +320,15 @@ def cli_verify_chain(file_path):
239
320
  with open(file_path, 'rb') as f:
240
321
  data = wavencode.wav_bytes_to_binary(f.read())
241
322
  else:
242
- with lzma.open(file_path, 'rb') as f:
323
+ with open(file_path, 'rb') as f:
243
324
  data = f.read()
325
+ if ecc == True:
326
+ data = recover_data(data)
327
+ if data is None:
328
+ print("❌ Error: データの復元に失敗しました")
329
+ return
330
+ else:
331
+ data = lzma.decompress(data)
244
332
  split_index = data.index(BLOCKCHAIN_HEADER)
245
333
  chain_json = data[split_index + len(BLOCKCHAIN_HEADER):].decode('utf-8')
246
334
  blockchain = Blockchain.from_json(chain_json)
@@ -275,7 +363,9 @@ def password_from_keyfile(keyfile_path: str) -> str:
275
363
  sys.exit(1)
276
364
 
277
365
  return hashlib.sha256(data).hexdigest()
366
+
278
367
  def main():
368
+ global ecc
279
369
  parser = argparse.ArgumentParser(description="EncryptSecureDEC CLI")
280
370
  parser.add_argument("mode",choices=["encrypt","decrypt","verify-chain","sign",
281
371
  "verify-sign","key-protect-on","key-protect-off"])
@@ -288,8 +378,14 @@ def main():
288
378
  parser.add_argument("--pubkey", help="Path to public key file (only required in RSA encrypt mode)")
289
379
  parser.add_argument("--keyfile",help="Use a file as the password source for encryption/decryption")
290
380
  parser.add_argument("--version",action="version",version=f"ENSEC_CLI {VERSION}")
381
+ parser.add_argument("--ecc", action="store_true",help="Enable ECC protection")
382
+ parser.add_argument("--split",action="store_true",help="Split encrypted file")
291
383
  args = parser.parse_args()
292
-
384
+ if args.split:
385
+ global is_split_enabled
386
+ is_split_enabled=True
387
+ if args.ecc:
388
+ ecc=True
293
389
  # --- validate RSA/pubkey usage ---
294
390
  # --- validate RSA/pubkey usage ---
295
391
  if args.rsa:
@@ -327,7 +423,7 @@ def main():
327
423
  sys.exit(1)
328
424
 
329
425
  # For decrypt mode, check extension
330
- if args.mode == "decrypt" and not (args.file.endswith(".vdec") or args.file.endswith(".rdec") or args.file.endswith(".esdc")):
426
+ if args.mode == "decrypt" and not (args.file.endswith(".vdec") or args.file.endswith(".rdec") or args.file.endswith(".esdc") or args.file.endswith(".vdec0")):
331
427
  print(f"❌ Error: The file for decryption must have a '.vdec' or '.rdec' extension.")
332
428
  sys.exit(1)
333
429
 
@@ -0,0 +1,29 @@
1
+ from reedsolo import RSCodec, ReedSolomonError
2
+ import lzma
3
+ ECC_BYTES = 32 # 32byteのECC。最大で約16byte分の破損を訂正可能
4
+ rs = RSCodec(ECC_BYTES)
5
+
6
+ def add_ecc(data: bytes) -> bytes:
7
+ """暗号化済みデータにECCを付与"""
8
+ return bytes(rs.encode(data))
9
+
10
+ def recover_ecc(encoded: bytes) -> bytes:
11
+ """ECC付きデータを復元"""
12
+ try:
13
+ decoded = rs.decode(encoded)
14
+ return bytes(decoded[0]) # 元データ
15
+ except ReedSolomonError as e:
16
+ raise ValueError("ECCで復元できないほど破損しています") from e
17
+
18
+ def recover_data(data):
19
+ try:
20
+ compressed_data = recover_ecc(data)
21
+ except ValueError as e:
22
+ return None
23
+
24
+ try:
25
+ data = lzma.decompress(compressed_data)
26
+ except lzma.LZMAError:
27
+ print("エラーLZMA圧縮データの展開に失敗しました")
28
+ return None
29
+ return data
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ensec-cli
3
- Version: 1.0.11
3
+ Version: 2.1.0
4
4
  Summary: EncryptSecureDEC Open Source CLI
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  Requires-Dist: pycryptodome
9
9
  Requires-Dist: cryptography
10
+ Requires-Dist: reedsolo
10
11
  Dynamic: license-file
11
12
 
12
13
  # ENSEC-CLI
@@ -2,8 +2,10 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  ensec_cli/__init__.py
5
+ ensec_cli/bin_ext.py
5
6
  ensec_cli/cli.py
6
7
  ensec_cli/conf_load.py
8
+ ensec_cli/ecc.py
7
9
  ensec_cli/esdcompress.py
8
10
  ensec_cli/passchk.py
9
11
  ensec_cli/rsa_encryptor.py
@@ -1,2 +1,3 @@
1
1
  pycryptodome
2
2
  cryptography
3
+ reedsolo
@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ensec-cli"
7
- version = "1.0.11"
7
+ version = "2.1.0"
8
8
  description = "EncryptSecureDEC Open Source CLI"
9
9
  requires-python = ">=3.9"
10
10
  readme = "README.md"
11
11
 
12
12
  dependencies = [
13
13
  "pycryptodome",
14
- "cryptography"
14
+ "cryptography",
15
+ "reedsolo"
15
16
  ]
16
17
  [tool.setuptools]
17
18
  packages = ["ensec_cli"]
@@ -1 +0,0 @@
1
- __version__ = "1.0.11"
File without changes
File without changes
File without changes
File without changes