|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import base64 |
| 4 | +import binascii |
| 5 | +import os |
| 6 | +import tempfile |
4 | 7 | from pathlib import Path |
5 | 8 |
|
6 | 9 |
|
7 | 10 | def bytes_to_base64(data: bytes) -> str: |
8 | | - """ |
9 | | - Encode bytes to a Base64 string. |
10 | | - """ |
11 | | - return base64.b64encode(data).decode("utf-8") |
| 11 | + """Encode bytes to a Base64 string.""" |
| 12 | + return base64.b64encode(data).decode("ascii") |
12 | 13 |
|
13 | 14 |
|
14 | 15 | def base64_to_bytes(data: str) -> bytes: |
15 | | - """ |
16 | | - Decode a Base64 string to bytes. |
17 | | - """ |
18 | | - return base64.b64decode(data.encode("utf-8")) |
| 16 | + """Strictly decode a Base64 string to bytes.""" |
| 17 | + try: |
| 18 | + return base64.b64decode(data.encode("ascii"), validate=True) |
| 19 | + except (UnicodeEncodeError, binascii.Error) as exc: |
| 20 | + raise ValueError("Invalid Base64 data") from exc |
19 | 21 |
|
20 | 22 |
|
21 | | -def save_binary_file(path: str | Path, data: bytes) -> None: |
22 | | - """ |
23 | | - Save raw binary data to disk. |
24 | | - """ |
| 23 | +def _atomic_write(path: str | Path, data: bytes) -> None: |
25 | 24 | file_path = Path(path) |
26 | 25 | file_path.parent.mkdir(parents=True, exist_ok=True) |
27 | | - file_path.write_bytes(data) |
| 26 | + descriptor, temporary_name = tempfile.mkstemp( |
| 27 | + prefix=f".{file_path.name}.", |
| 28 | + dir=file_path.parent, |
| 29 | + ) |
| 30 | + temporary_path = Path(temporary_name) |
| 31 | + try: |
| 32 | + os.fchmod(descriptor, 0o600) |
| 33 | + with os.fdopen(descriptor, "wb") as stream: |
| 34 | + stream.write(data) |
| 35 | + stream.flush() |
| 36 | + os.fsync(stream.fileno()) |
| 37 | + os.replace(temporary_path, file_path) |
| 38 | + try: |
| 39 | + file_path.chmod(0o600) |
| 40 | + except OSError: |
| 41 | + pass |
| 42 | + except Exception: |
| 43 | + try: |
| 44 | + os.close(descriptor) |
| 45 | + except OSError: |
| 46 | + pass |
| 47 | + temporary_path.unlink(missing_ok=True) |
| 48 | + raise |
| 49 | + |
| 50 | + |
| 51 | +def save_binary_file(path: str | Path, data: bytes) -> None: |
| 52 | + """Atomically save binary key material with owner-only permissions.""" |
| 53 | + _atomic_write(path, data) |
28 | 54 |
|
29 | 55 |
|
30 | 56 | def load_binary_file(path: str | Path) -> bytes: |
31 | | - """ |
32 | | - Load raw binary data from disk. |
33 | | - """ |
34 | 57 | return Path(path).read_bytes() |
35 | 58 |
|
36 | 59 |
|
37 | 60 | def save_base64_file(path: str | Path, data: bytes) -> None: |
38 | | - """ |
39 | | - Save binary data as Base64 text. |
40 | | - """ |
41 | | - file_path = Path(path) |
42 | | - file_path.parent.mkdir(parents=True, exist_ok=True) |
43 | | - file_path.write_text( |
44 | | - bytes_to_base64(data), |
45 | | - encoding="utf-8", |
46 | | - ) |
| 61 | + """Atomically save Base64 key material with owner-only permissions.""" |
| 62 | + _atomic_write(path, bytes_to_base64(data).encode("ascii")) |
47 | 63 |
|
48 | 64 |
|
49 | 65 | def load_base64_file(path: str | Path) -> bytes: |
50 | | - """ |
51 | | - Load Base64 text and decode it to bytes. |
52 | | - """ |
53 | | - data = Path(path).read_text(encoding="utf-8") |
54 | | - return base64_to_bytes(data) |
| 66 | + return base64_to_bytes(Path(path).read_text(encoding="ascii").strip()) |
55 | 67 |
|
56 | 68 |
|
57 | 69 | def serialize_pqc_key_to_json_value(data: bytes) -> str: |
58 | | - """ |
59 | | - Serialize a PQC key to a JSON-compatible Base64 string. |
60 | | - """ |
61 | 70 | return bytes_to_base64(data) |
62 | 71 |
|
63 | 72 |
|
64 | 73 | def deserialize_pqc_key_from_json_value(data: str) -> bytes: |
65 | | - """ |
66 | | - Deserialize a PQC key from a JSON-compatible Base64 string. |
67 | | - """ |
68 | 74 | return base64_to_bytes(data) |
0 commit comments