uag-envsec 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.
- uag_envsec/__init__.py +54 -0
- uag_envsec/cli.py +641 -0
- uag_envsec/secret_core.py +809 -0
- uag_envsec-0.1.0.dist-info/METADATA +256 -0
- uag_envsec-0.1.0.dist-info/RECORD +9 -0
- uag_envsec-0.1.0.dist-info/WHEEL +5 -0
- uag_envsec-0.1.0.dist-info/entry_points.txt +2 -0
- uag_envsec-0.1.0.dist-info/licenses/LICENSE +203 -0
- uag_envsec-0.1.0.dist-info/top_level.txt +1 -0
uag_envsec/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""uag_envsec
|
|
2
|
+
|
|
3
|
+
Standalone helpers for encrypting/decrypting environment files and values.
|
|
4
|
+
|
|
5
|
+
This package is intentionally small and can be reused outside uagent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .secret_core import (
|
|
9
|
+
CURRENT_FORMAT_VERSION,
|
|
10
|
+
DEFAULT_KEY_FILENAME,
|
|
11
|
+
DEFAULT_SEC_SUFFIX,
|
|
12
|
+
KEYRING_SERVICE,
|
|
13
|
+
KEYRING_USERNAME,
|
|
14
|
+
LEGACY_FORMAT_VERSION,
|
|
15
|
+
UnsupportedCiphertextVersionError,
|
|
16
|
+
decrypt_file,
|
|
17
|
+
decrypt_text,
|
|
18
|
+
detect_format_version,
|
|
19
|
+
encrypt_file,
|
|
20
|
+
encrypt_text,
|
|
21
|
+
ensure_key_file,
|
|
22
|
+
inspect_file,
|
|
23
|
+
load_key,
|
|
24
|
+
migrate_file,
|
|
25
|
+
migrate_key_file_to_keyring,
|
|
26
|
+
migrate_text,
|
|
27
|
+
rekey_file,
|
|
28
|
+
rekey_text,
|
|
29
|
+
save_key,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CURRENT_FORMAT_VERSION",
|
|
34
|
+
"DEFAULT_KEY_FILENAME",
|
|
35
|
+
"DEFAULT_SEC_SUFFIX",
|
|
36
|
+
"KEYRING_SERVICE",
|
|
37
|
+
"KEYRING_USERNAME",
|
|
38
|
+
"LEGACY_FORMAT_VERSION",
|
|
39
|
+
"UnsupportedCiphertextVersionError",
|
|
40
|
+
"decrypt_file",
|
|
41
|
+
"decrypt_text",
|
|
42
|
+
"detect_format_version",
|
|
43
|
+
"encrypt_file",
|
|
44
|
+
"encrypt_text",
|
|
45
|
+
"ensure_key_file",
|
|
46
|
+
"inspect_file",
|
|
47
|
+
"load_key",
|
|
48
|
+
"migrate_file",
|
|
49
|
+
"migrate_key_file_to_keyring",
|
|
50
|
+
"migrate_text",
|
|
51
|
+
"rekey_file",
|
|
52
|
+
"rekey_text",
|
|
53
|
+
"save_key",
|
|
54
|
+
]
|
uag_envsec/cli.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""CLI for encrypting and updating .env.sec files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .secret_core import (
|
|
14
|
+
CURRENT_FORMAT_VERSION,
|
|
15
|
+
DEFAULT_SEC_SUFFIX,
|
|
16
|
+
LEGACY_FORMAT_VERSION,
|
|
17
|
+
decrypt_file,
|
|
18
|
+
decrypt_text,
|
|
19
|
+
detect_format_version,
|
|
20
|
+
default_key_path,
|
|
21
|
+
encrypt_text,
|
|
22
|
+
encrypt_file,
|
|
23
|
+
ensure_key_file,
|
|
24
|
+
inspect_file,
|
|
25
|
+
load_key,
|
|
26
|
+
migrate_file,
|
|
27
|
+
rekey_file,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_ENV_KEY_RE = re.compile(
|
|
31
|
+
r"^\s*(?:export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=", re.MULTILINE
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_encrypt_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="uag_envsec",
|
|
38
|
+
description="Encrypt a .env file into .env.sec.",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"path",
|
|
42
|
+
nargs="?",
|
|
43
|
+
default=".env",
|
|
44
|
+
help="Path to the input .env file (default: .env)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--output",
|
|
48
|
+
default=None,
|
|
49
|
+
help="Output path for the encrypted file (default: input path + .sec)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--key-file",
|
|
53
|
+
default=None,
|
|
54
|
+
help="Key file path (default: ~/.uag/uag_envsec_key)",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--force",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="Overwrite the output file if it already exists",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--format-version",
|
|
63
|
+
type=int,
|
|
64
|
+
choices=[LEGACY_FORMAT_VERSION, CURRENT_FORMAT_VERSION],
|
|
65
|
+
default=CURRENT_FORMAT_VERSION,
|
|
66
|
+
help=(
|
|
67
|
+
"Ciphertext envelope version (default: 1; use 0 for older-uag compatibility)"
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_decrypt_parser() -> argparse.ArgumentParser:
|
|
74
|
+
parser = argparse.ArgumentParser(
|
|
75
|
+
prog="uag_envsec decrypt",
|
|
76
|
+
description="Decrypt an encrypted env file to a private output file.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument("--file", required=True, help="Encrypted input file")
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--output",
|
|
81
|
+
default=None,
|
|
82
|
+
help="Plaintext output path (default: append .decrypted to the input filename)",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--key-file",
|
|
86
|
+
default=None,
|
|
87
|
+
help="Key file path (default: configured key backend)",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--force",
|
|
91
|
+
action="store_true",
|
|
92
|
+
help="Overwrite an existing output file (the encrypted source is never overwritten)",
|
|
93
|
+
)
|
|
94
|
+
return parser
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_inspect_parser() -> argparse.ArgumentParser:
|
|
98
|
+
parser = argparse.ArgumentParser(
|
|
99
|
+
prog="uag_envsec inspect",
|
|
100
|
+
description="Inspect ciphertext version and verify that it can be decrypted.",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument("--file", required=True, help="Encrypted input file")
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--key-file",
|
|
105
|
+
default=None,
|
|
106
|
+
help="Key file path (default: configured key backend)",
|
|
107
|
+
)
|
|
108
|
+
return parser
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_add_parser() -> argparse.ArgumentParser:
|
|
112
|
+
parser = argparse.ArgumentParser(
|
|
113
|
+
prog="uag_envsec add",
|
|
114
|
+
description="Add or update a variable inside an encrypted .env.sec file.",
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--file",
|
|
118
|
+
default=".env.sec",
|
|
119
|
+
help="Path to the encrypted .env.sec file (default: .env.sec)",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--output",
|
|
123
|
+
default=None,
|
|
124
|
+
help="Output path (default: overwrite the input file)",
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--key-file",
|
|
128
|
+
default=None,
|
|
129
|
+
help="Key file path (default: ~/.uag/uag_envsec_key)",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--key",
|
|
133
|
+
default=None,
|
|
134
|
+
help="Environment variable name",
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--value",
|
|
138
|
+
default=None,
|
|
139
|
+
help="Environment variable value",
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument(
|
|
142
|
+
"--force",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="Overwrite the output file if it already exists",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--format-version",
|
|
148
|
+
type=int,
|
|
149
|
+
choices=[LEGACY_FORMAT_VERSION, CURRENT_FORMAT_VERSION],
|
|
150
|
+
default=CURRENT_FORMAT_VERSION,
|
|
151
|
+
help="Output ciphertext version (default: 1; use 0 for older uag)",
|
|
152
|
+
)
|
|
153
|
+
return parser
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _build_delete_parser() -> argparse.ArgumentParser:
|
|
157
|
+
parser = argparse.ArgumentParser(
|
|
158
|
+
prog="uag_envsec delete",
|
|
159
|
+
description="Delete a variable from an encrypted .env.sec file.",
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--file",
|
|
163
|
+
default=".env.sec",
|
|
164
|
+
help="Path to the encrypted .env.sec file (default: .env.sec)",
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--output",
|
|
168
|
+
default=None,
|
|
169
|
+
help="Output path (default: overwrite the input file)",
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--key-file",
|
|
173
|
+
default=None,
|
|
174
|
+
help="Key file path (default: ~/.uag/uag_envsec_key)",
|
|
175
|
+
)
|
|
176
|
+
parser.add_argument(
|
|
177
|
+
"--key",
|
|
178
|
+
required=True,
|
|
179
|
+
help="Environment variable name to delete",
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--force",
|
|
183
|
+
action="store_true",
|
|
184
|
+
help="Overwrite the output file if it already exists",
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--format-version",
|
|
188
|
+
type=int,
|
|
189
|
+
choices=[LEGACY_FORMAT_VERSION, CURRENT_FORMAT_VERSION],
|
|
190
|
+
default=CURRENT_FORMAT_VERSION,
|
|
191
|
+
help="Output ciphertext version (default: 1; use 0 for older uag)",
|
|
192
|
+
)
|
|
193
|
+
return parser
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _build_migrate_parser() -> argparse.ArgumentParser:
|
|
197
|
+
parser = argparse.ArgumentParser(
|
|
198
|
+
prog="uag_envsec migrate",
|
|
199
|
+
description="Migrate an encrypted env file to another format version.",
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--file",
|
|
203
|
+
required=True,
|
|
204
|
+
help="Encrypted input file",
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--to",
|
|
208
|
+
"--format-version",
|
|
209
|
+
dest="target_version",
|
|
210
|
+
type=int,
|
|
211
|
+
choices=[LEGACY_FORMAT_VERSION, CURRENT_FORMAT_VERSION],
|
|
212
|
+
default=CURRENT_FORMAT_VERSION,
|
|
213
|
+
help="Target ciphertext format version (default: 1)",
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"--output",
|
|
217
|
+
default=None,
|
|
218
|
+
help="Output path (default: append .v<version> to the input filename)",
|
|
219
|
+
)
|
|
220
|
+
parser.add_argument(
|
|
221
|
+
"--key-file",
|
|
222
|
+
default=None,
|
|
223
|
+
help="Key file path (default: configured key backend)",
|
|
224
|
+
)
|
|
225
|
+
parser.add_argument(
|
|
226
|
+
"--force",
|
|
227
|
+
action="store_true",
|
|
228
|
+
help="Overwrite an existing output file (the source is never overwritten)",
|
|
229
|
+
)
|
|
230
|
+
return parser
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _build_rekey_parser() -> argparse.ArgumentParser:
|
|
234
|
+
parser = argparse.ArgumentParser(
|
|
235
|
+
prog="uag_envsec rekey",
|
|
236
|
+
description="Re-encrypt an encrypted file using a different key.",
|
|
237
|
+
)
|
|
238
|
+
parser.add_argument("--file", required=True, help="Encrypted input file")
|
|
239
|
+
parser.add_argument(
|
|
240
|
+
"--new-key-file",
|
|
241
|
+
required=True,
|
|
242
|
+
help="Key file to use for the new ciphertext; created if missing",
|
|
243
|
+
)
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
"--old-key-file",
|
|
246
|
+
default=None,
|
|
247
|
+
help="Current key file (default: configured key backend)",
|
|
248
|
+
)
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"--output",
|
|
251
|
+
default=None,
|
|
252
|
+
help="Output path (default: append .rekeyed to the input filename)",
|
|
253
|
+
)
|
|
254
|
+
parser.add_argument(
|
|
255
|
+
"--format-version",
|
|
256
|
+
type=int,
|
|
257
|
+
choices=[LEGACY_FORMAT_VERSION, CURRENT_FORMAT_VERSION],
|
|
258
|
+
default=CURRENT_FORMAT_VERSION,
|
|
259
|
+
help="Output envelope version (default: 1; use 0 for older uag)",
|
|
260
|
+
)
|
|
261
|
+
parser.add_argument(
|
|
262
|
+
"--force",
|
|
263
|
+
action="store_true",
|
|
264
|
+
help="Overwrite an existing output file (the source is never overwritten)",
|
|
265
|
+
)
|
|
266
|
+
return parser
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _format_env_value(value: str) -> str:
|
|
270
|
+
if value == "":
|
|
271
|
+
return '""'
|
|
272
|
+
if "\n" in value or "\r" in value:
|
|
273
|
+
escaped = (
|
|
274
|
+
value.replace("\\", "\\\\")
|
|
275
|
+
.replace('"', '\\"')
|
|
276
|
+
.replace("\n", "\\n")
|
|
277
|
+
.replace("\r", "\\r")
|
|
278
|
+
)
|
|
279
|
+
return f'"{escaped}"'
|
|
280
|
+
if re.fullmatch(r"[A-Za-z0-9_./:@%+=,-]+", value):
|
|
281
|
+
return value
|
|
282
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
283
|
+
return f'"{escaped}"'
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _upsert_env_text(text: str, key: str, value: str) -> str:
|
|
287
|
+
line = f"{key}={_format_env_value(value)}"
|
|
288
|
+
lines = text.splitlines()
|
|
289
|
+
for idx, current in enumerate(lines):
|
|
290
|
+
match = _ENV_KEY_RE.match(current)
|
|
291
|
+
if match and match.group("key") == key:
|
|
292
|
+
lines[idx] = line
|
|
293
|
+
break
|
|
294
|
+
else:
|
|
295
|
+
lines.append(line)
|
|
296
|
+
|
|
297
|
+
updated = "\n".join(lines)
|
|
298
|
+
if text.endswith(("\n", "\r")):
|
|
299
|
+
return updated + "\n"
|
|
300
|
+
return updated
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _read_key_value_from_args(args: argparse.Namespace) -> tuple[str, str]:
|
|
304
|
+
if args.key is not None and args.value is not None:
|
|
305
|
+
key = args.key.strip()
|
|
306
|
+
value = args.value
|
|
307
|
+
return key, value
|
|
308
|
+
|
|
309
|
+
if args.key is not None and args.value is None:
|
|
310
|
+
key = args.key.strip()
|
|
311
|
+
value = getpass.getpass("Value: ")
|
|
312
|
+
return key, value
|
|
313
|
+
|
|
314
|
+
if args.key is None and args.value is not None:
|
|
315
|
+
raise SystemExit("--value requires --key")
|
|
316
|
+
|
|
317
|
+
key = input("Key: ").strip()
|
|
318
|
+
if not key:
|
|
319
|
+
raise SystemExit("Key is required")
|
|
320
|
+
value = getpass.getpass("Value: ")
|
|
321
|
+
return key, value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _resolve_key_file(raw: str | None) -> Path:
|
|
325
|
+
return Path(raw) if raw else default_key_path()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _write_text_atomic(
|
|
329
|
+
path: Path,
|
|
330
|
+
content: str,
|
|
331
|
+
*,
|
|
332
|
+
overwrite: bool = True,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Write encrypted text through a private, unique file in the target dir."""
|
|
335
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
337
|
+
prefix=f".{path.name}.",
|
|
338
|
+
suffix=".tmp",
|
|
339
|
+
dir=str(path.parent),
|
|
340
|
+
)
|
|
341
|
+
temporary_path = Path(temporary_name)
|
|
342
|
+
try:
|
|
343
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream:
|
|
344
|
+
stream.write(content)
|
|
345
|
+
stream.flush()
|
|
346
|
+
os.fsync(stream.fileno())
|
|
347
|
+
if overwrite:
|
|
348
|
+
os.replace(temporary_path, path)
|
|
349
|
+
else:
|
|
350
|
+
try:
|
|
351
|
+
os.link(temporary_path, path)
|
|
352
|
+
except FileExistsError as exc:
|
|
353
|
+
raise FileExistsError(f"Output file already exists: {path}") from exc
|
|
354
|
+
else:
|
|
355
|
+
temporary_path.unlink()
|
|
356
|
+
finally:
|
|
357
|
+
try:
|
|
358
|
+
temporary_path.unlink()
|
|
359
|
+
except FileNotFoundError:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _run_encrypt(args: argparse.Namespace) -> int:
|
|
364
|
+
src_path = Path(args.path)
|
|
365
|
+
if not src_path.is_file():
|
|
366
|
+
print(f"Input file not found: {src_path}", file=sys.stderr)
|
|
367
|
+
return 1
|
|
368
|
+
|
|
369
|
+
out_path = (
|
|
370
|
+
Path(args.output) if args.output else Path(str(src_path) + DEFAULT_SEC_SUFFIX)
|
|
371
|
+
)
|
|
372
|
+
if out_path.exists() and not args.force:
|
|
373
|
+
print(f"Output file already exists: {out_path} (use --force)", file=sys.stderr)
|
|
374
|
+
return 1
|
|
375
|
+
|
|
376
|
+
key_file = _resolve_key_file(args.key_file) if args.key_file else None
|
|
377
|
+
try:
|
|
378
|
+
ensure_key_file(key_file)
|
|
379
|
+
result_path = encrypt_file(
|
|
380
|
+
src_path,
|
|
381
|
+
key_path=key_file,
|
|
382
|
+
output_path=out_path,
|
|
383
|
+
format_version=args.format_version,
|
|
384
|
+
overwrite=args.force,
|
|
385
|
+
)
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
print(f"Failed to encrypt {src_path}: {exc}", file=sys.stderr)
|
|
388
|
+
return 1
|
|
389
|
+
print(str(result_path))
|
|
390
|
+
return 0
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _run_decrypt(args: argparse.Namespace) -> int:
|
|
394
|
+
source_path = Path(args.file)
|
|
395
|
+
if not source_path.is_file():
|
|
396
|
+
print(f"Encrypted input file not found: {source_path}", file=sys.stderr)
|
|
397
|
+
return 1
|
|
398
|
+
|
|
399
|
+
destination = (
|
|
400
|
+
Path(args.output)
|
|
401
|
+
if args.output
|
|
402
|
+
else Path(f"{source_path}.decrypted")
|
|
403
|
+
)
|
|
404
|
+
if source_path.resolve() == destination.resolve():
|
|
405
|
+
print("Decrypted output must differ from the encrypted source", file=sys.stderr)
|
|
406
|
+
return 1
|
|
407
|
+
if destination.exists() and not args.force:
|
|
408
|
+
print(f"Decrypted output already exists: {destination}", file=sys.stderr)
|
|
409
|
+
return 1
|
|
410
|
+
|
|
411
|
+
key_file = Path(args.key_file) if args.key_file else None
|
|
412
|
+
try:
|
|
413
|
+
result_path = decrypt_file(
|
|
414
|
+
source_path,
|
|
415
|
+
key_path=key_file,
|
|
416
|
+
output_path=destination,
|
|
417
|
+
overwrite=args.force,
|
|
418
|
+
)
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
print(f"Failed to decrypt {source_path}: {exc}", file=sys.stderr)
|
|
421
|
+
return 1
|
|
422
|
+
|
|
423
|
+
print(str(result_path))
|
|
424
|
+
return 0
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _run_inspect(args: argparse.Namespace) -> int:
|
|
428
|
+
source_path = Path(args.file)
|
|
429
|
+
key_file = Path(args.key_file) if args.key_file else None
|
|
430
|
+
try:
|
|
431
|
+
result = inspect_file(source_path, key_path=key_file)
|
|
432
|
+
except Exception as exc:
|
|
433
|
+
print(f"Inspection failed: {exc}", file=sys.stderr)
|
|
434
|
+
return 1
|
|
435
|
+
|
|
436
|
+
print(f"format_version: {result['format_version']}")
|
|
437
|
+
print(f"ciphertext_bytes: {result['ciphertext_bytes']}")
|
|
438
|
+
print(f"authenticated: {'yes' if result['authenticated'] else 'no'}")
|
|
439
|
+
print(f"decryptable: {'yes' if result['decryptable'] else 'no'}")
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _run_add(args: argparse.Namespace) -> int:
|
|
444
|
+
enc_path = Path(args.file)
|
|
445
|
+
if not enc_path.exists():
|
|
446
|
+
print(f"Encrypted file not found: {enc_path}", file=sys.stderr)
|
|
447
|
+
return 1
|
|
448
|
+
|
|
449
|
+
out_path = Path(args.output) if args.output else enc_path
|
|
450
|
+
if (
|
|
451
|
+
out_path.exists()
|
|
452
|
+
and out_path.resolve() != enc_path.resolve()
|
|
453
|
+
and not args.force
|
|
454
|
+
):
|
|
455
|
+
print(f"Output file already exists: {out_path} (use --force)", file=sys.stderr)
|
|
456
|
+
return 1
|
|
457
|
+
|
|
458
|
+
key_file = _resolve_key_file(args.key_file) if args.key_file else None
|
|
459
|
+
if key_file is not None and not key_file.exists():
|
|
460
|
+
print(f"Key file not found: {key_file}", file=sys.stderr)
|
|
461
|
+
return 1
|
|
462
|
+
ensure_key_file(key_file)
|
|
463
|
+
|
|
464
|
+
key, value = _read_key_value_from_args(args)
|
|
465
|
+
if not key:
|
|
466
|
+
print("Key is required", file=sys.stderr)
|
|
467
|
+
return 1
|
|
468
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
469
|
+
print(f"Invalid environment variable name: {key}", file=sys.stderr)
|
|
470
|
+
return 1
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
encrypted_input = enc_path.read_text(encoding="utf-8").strip()
|
|
474
|
+
detect_format_version(encrypted_input)
|
|
475
|
+
plaintext = decrypt_text(encrypted_input, key_path=key_file)
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
print(f"Failed to decrypt {enc_path}: {exc}", file=sys.stderr)
|
|
478
|
+
return 1
|
|
479
|
+
|
|
480
|
+
updated = _upsert_env_text(plaintext, key, value)
|
|
481
|
+
_write_text_atomic(
|
|
482
|
+
out_path,
|
|
483
|
+
encrypt_text(updated, key_path=key_file, format_version=args.format_version),
|
|
484
|
+
overwrite=args.force or out_path.resolve() == enc_path.resolve(),
|
|
485
|
+
)
|
|
486
|
+
print(str(out_path))
|
|
487
|
+
return 0
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _run_delete(args: argparse.Namespace) -> int:
|
|
491
|
+
enc_path = Path(args.file)
|
|
492
|
+
if not enc_path.exists():
|
|
493
|
+
print(f"Encrypted file not found: {enc_path}", file=sys.stderr)
|
|
494
|
+
return 1
|
|
495
|
+
|
|
496
|
+
out_path = Path(args.output) if args.output else enc_path
|
|
497
|
+
if (
|
|
498
|
+
out_path.exists()
|
|
499
|
+
and out_path.resolve() != enc_path.resolve()
|
|
500
|
+
and not args.force
|
|
501
|
+
):
|
|
502
|
+
print(f"Output file already exists: {out_path} (use --force)", file=sys.stderr)
|
|
503
|
+
return 1
|
|
504
|
+
|
|
505
|
+
key = args.key.strip()
|
|
506
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
507
|
+
print(f"Invalid environment variable name: {key}", file=sys.stderr)
|
|
508
|
+
return 1
|
|
509
|
+
|
|
510
|
+
key_file = _resolve_key_file(args.key_file) if args.key_file else None
|
|
511
|
+
if key_file is not None and not key_file.exists():
|
|
512
|
+
print(f"Key file not found: {key_file}", file=sys.stderr)
|
|
513
|
+
return 1
|
|
514
|
+
ensure_key_file(key_file)
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
encrypted_input = enc_path.read_text(encoding="utf-8").strip()
|
|
518
|
+
detect_format_version(encrypted_input)
|
|
519
|
+
plaintext = decrypt_text(encrypted_input, key_path=key_file)
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
print(f"Failed to decrypt {enc_path}: {exc}", file=sys.stderr)
|
|
522
|
+
return 1
|
|
523
|
+
|
|
524
|
+
pattern = re.compile(
|
|
525
|
+
rf"^[ \t]*(?:export[ \t]+)?{re.escape(key)}[ \t]*=[^\r\n]*(?:\r\n|\n|\r|$)",
|
|
526
|
+
re.MULTILINE,
|
|
527
|
+
)
|
|
528
|
+
updated, count = pattern.subn("", plaintext)
|
|
529
|
+
if count == 0:
|
|
530
|
+
print(f"Environment variable not found: {key}", file=sys.stderr)
|
|
531
|
+
return 1
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
_write_text_atomic(
|
|
535
|
+
out_path,
|
|
536
|
+
encrypt_text(updated, key_path=key_file, format_version=args.format_version),
|
|
537
|
+
overwrite=args.force or out_path.resolve() == enc_path.resolve(),
|
|
538
|
+
)
|
|
539
|
+
except Exception as exc:
|
|
540
|
+
print(f"Failed to encrypt {out_path}: {exc}", file=sys.stderr)
|
|
541
|
+
return 1
|
|
542
|
+
|
|
543
|
+
print(str(out_path))
|
|
544
|
+
return 0
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _run_migrate(args: argparse.Namespace) -> int:
|
|
548
|
+
source_path = Path(args.file)
|
|
549
|
+
if not source_path.is_file():
|
|
550
|
+
print(f"Encrypted input file not found: {source_path}", file=sys.stderr)
|
|
551
|
+
return 1
|
|
552
|
+
|
|
553
|
+
key_file = Path(args.key_file) if args.key_file else None
|
|
554
|
+
try:
|
|
555
|
+
result_path = migrate_file(
|
|
556
|
+
source_path,
|
|
557
|
+
target_version=args.target_version,
|
|
558
|
+
key_path=key_file,
|
|
559
|
+
output_path=args.output,
|
|
560
|
+
overwrite=args.force,
|
|
561
|
+
)
|
|
562
|
+
except Exception as exc:
|
|
563
|
+
print(f"Failed to migrate {source_path}: {exc}", file=sys.stderr)
|
|
564
|
+
return 1
|
|
565
|
+
|
|
566
|
+
if result_path.resolve() == source_path.resolve() and args.output is None:
|
|
567
|
+
print(
|
|
568
|
+
f"{source_path} is already format version {args.target_version}; "
|
|
569
|
+
"no migration needed."
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
print(str(result_path))
|
|
573
|
+
return 0
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _run_rekey(args: argparse.Namespace) -> int:
|
|
577
|
+
source_path = Path(args.file)
|
|
578
|
+
if not source_path.is_file():
|
|
579
|
+
print(f"Encrypted input file not found: {source_path}", file=sys.stderr)
|
|
580
|
+
return 1
|
|
581
|
+
|
|
582
|
+
destination = (
|
|
583
|
+
Path(args.output) if args.output else Path(f"{source_path}.rekeyed")
|
|
584
|
+
)
|
|
585
|
+
if source_path.resolve() == destination.resolve():
|
|
586
|
+
print("Rekey output must differ from the source file", file=sys.stderr)
|
|
587
|
+
return 1
|
|
588
|
+
if destination.exists() and not args.force:
|
|
589
|
+
print(f"Rekey output already exists: {destination}", file=sys.stderr)
|
|
590
|
+
return 1
|
|
591
|
+
|
|
592
|
+
old_key_path = Path(args.old_key_file) if args.old_key_file else None
|
|
593
|
+
new_key_path = Path(args.new_key_file)
|
|
594
|
+
try:
|
|
595
|
+
load_key(old_key_path)
|
|
596
|
+
ensure_key_file(new_key_path)
|
|
597
|
+
result_path = rekey_file(
|
|
598
|
+
source_path,
|
|
599
|
+
new_key_path=new_key_path,
|
|
600
|
+
old_key_path=old_key_path,
|
|
601
|
+
output_path=destination,
|
|
602
|
+
format_version=args.format_version,
|
|
603
|
+
overwrite=args.force,
|
|
604
|
+
)
|
|
605
|
+
except Exception as exc:
|
|
606
|
+
print(f"Failed to rekey {source_path}: {exc}", file=sys.stderr)
|
|
607
|
+
return 1
|
|
608
|
+
|
|
609
|
+
print(str(result_path))
|
|
610
|
+
return 0
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def main(argv: list[str] | None = None) -> int:
|
|
614
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
615
|
+
if args and args[0] == "inspect":
|
|
616
|
+
parsed = _build_inspect_parser().parse_args(args[1:])
|
|
617
|
+
return _run_inspect(parsed)
|
|
618
|
+
if args and args[0] == "decrypt":
|
|
619
|
+
parsed = _build_decrypt_parser().parse_args(args[1:])
|
|
620
|
+
return _run_decrypt(parsed)
|
|
621
|
+
if args and args[0] == "add":
|
|
622
|
+
parsed = _build_add_parser().parse_args(args[1:])
|
|
623
|
+
return _run_add(parsed)
|
|
624
|
+
if args and args[0] == "delete":
|
|
625
|
+
parsed = _build_delete_parser().parse_args(args[1:])
|
|
626
|
+
return _run_delete(parsed)
|
|
627
|
+
if args and args[0] == "migrate":
|
|
628
|
+
parsed = _build_migrate_parser().parse_args(args[1:])
|
|
629
|
+
return _run_migrate(parsed)
|
|
630
|
+
if args and args[0] == "rekey":
|
|
631
|
+
parsed = _build_rekey_parser().parse_args(args[1:])
|
|
632
|
+
return _run_rekey(parsed)
|
|
633
|
+
if args and args[0] == "encrypt":
|
|
634
|
+
parsed = _build_encrypt_parser().parse_args(args[1:])
|
|
635
|
+
return _run_encrypt(parsed)
|
|
636
|
+
parsed = _build_encrypt_parser().parse_args(args)
|
|
637
|
+
return _run_encrypt(parsed)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
if __name__ == "__main__":
|
|
641
|
+
raise SystemExit(main())
|