scalio-backup 0.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.
- scalio_backup-0.1.0/PKG-INFO +80 -0
- scalio_backup-0.1.0/README.md +61 -0
- scalio_backup-0.1.0/pyproject.toml +29 -0
- scalio_backup-0.1.0/setup.cfg +4 -0
- scalio_backup-0.1.0/src/scalio_backup/__init__.py +24 -0
- scalio_backup-0.1.0/src/scalio_backup/age.py +237 -0
- scalio_backup-0.1.0/src/scalio_backup/engine.py +495 -0
- scalio_backup-0.1.0/src/scalio_backup/spec.py +60 -0
- scalio_backup-0.1.0/src/scalio_backup.egg-info/PKG-INFO +80 -0
- scalio_backup-0.1.0/src/scalio_backup.egg-info/SOURCES.txt +14 -0
- scalio_backup-0.1.0/src/scalio_backup.egg-info/dependency_links.txt +1 -0
- scalio_backup-0.1.0/src/scalio_backup.egg-info/requires.txt +12 -0
- scalio_backup-0.1.0/src/scalio_backup.egg-info/top_level.txt +1 -0
- scalio_backup-0.1.0/tests/test_age.py +73 -0
- scalio_backup-0.1.0/tests/test_engine.py +326 -0
- scalio_backup-0.1.0/tests/test_vector.py +31 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scalio-backup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Encrypted export and import (backup, restore, move) shared by the Scalio apps
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: cryptography>=42
|
|
9
|
+
Requires-Dist: pymongo>=4.6
|
|
10
|
+
Requires-Dist: packaging>=23
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
14
|
+
Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
|
|
15
|
+
Requires-Dist: typing-extensions>=4.8; extra == "dev"
|
|
16
|
+
Requires-Dist: pyrage>=1.1; extra == "dev"
|
|
17
|
+
Requires-Dist: motor>=3.3; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# scalio-backup
|
|
21
|
+
|
|
22
|
+
Encrypted export and import of a whole Scalio app instance, shared by the Scalio apps (Nexus first, then
|
|
23
|
+
Foundry, Rosetta, Citadel). Used for backup/restore and for moving an instance between self-hosted and SaaS.
|
|
24
|
+
Concept and decisions E1-E12: https://claude.ai/artifact/Ej8DFrgi4hcFDQ69dvKWZ7
|
|
25
|
+
|
|
26
|
+
Nothing in this package is secret. The password is the customer's; without it nobody (Scalio included) can
|
|
27
|
+
open a bundle.
|
|
28
|
+
|
|
29
|
+
## Bundle format (`.scaliobak`)
|
|
30
|
+
|
|
31
|
+
- Outside: standard [age v1](https://age-encryption.org/v1) with a passphrase (scrypt, work factor 2^18),
|
|
32
|
+
ChaCha20-Poly1305 in 64 KiB chunks. `age -d backup.scaliobak > backup.tar` opens it without Scalio.
|
|
33
|
+
- Inside: a tar stream. `manifest.json` first (format `scalio-backup/1`, product, app and schema version,
|
|
34
|
+
source, options, SHA-256 per part), then `collections/<name>.jsonl` (MongoDB Extended JSON, canonical, so
|
|
35
|
+
dates, Int64 and ObjectIds stay exact), `meta.jsonl`, and for file folders `files/<dir>.index.jsonl` plus the
|
|
36
|
+
files. The import checks every hash; a changed or incomplete bundle is refused before anything is written.
|
|
37
|
+
|
|
38
|
+
## Classes
|
|
39
|
+
|
|
40
|
+
Every collection (and every document of a mixed `meta` collection) has one class: `config` (always exported),
|
|
41
|
+
`data` (exported unless "configuration only"), `cache`, `ephemeral`, `instance` (never exported). Each app keeps
|
|
42
|
+
its classes in its own `backup_spec.py` and a test that fails on an unclassified collection.
|
|
43
|
+
|
|
44
|
+
## Use in an app
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from scalio_backup import AppSpec, Preserve, apply_import, export_instance, open_bundle, recover, rollback, purge
|
|
48
|
+
|
|
49
|
+
SPEC = AppSpec(product="scalio-nexus", app_version=VERSION, collections=..., meta_ids=...,
|
|
50
|
+
secret_fields={"settings": ("entra.clientSecret", ...)}, file_dirs={"hr": HR_UPLOAD_DIR},
|
|
51
|
+
schema_version=current_schema, export_hook=decrypt_instance_secrets, import_hook=encrypt_again)
|
|
52
|
+
|
|
53
|
+
# export (background job): writes the encrypted bundle to a file object
|
|
54
|
+
manifest = await export_instance(db, SPEC, out, password, include_data=True, include_secrets=True,
|
|
55
|
+
created_by=user_email, source={"mode": "selfhosted", "host": host, "tenant": tid})
|
|
56
|
+
|
|
57
|
+
# import, step 1: decrypt and verify into a work folder, nothing changes yet (WrongPassword, BundleError,
|
|
58
|
+
# IncompatibleBundle when the product differs or the bundle is newer than this instance)
|
|
59
|
+
bundle = open_bundle(upload, password, SPEC, target_schema=await current_schema(db))
|
|
60
|
+
show(bundle.preview()) # source, counts, warnings; the app adds its own (other tenant, license limits)
|
|
61
|
+
|
|
62
|
+
# import, step 2: inside the app's maintenance window (no writes, background jobs paused)
|
|
63
|
+
await apply_import(db, SPEC, bundle, preserve=(Preserve("app_users", {"email": me}),), imported_by=me)
|
|
64
|
+
bundle.cleanup()
|
|
65
|
+
# then: migrations, end all sessions, re-sync caches, show the checklist
|
|
66
|
+
|
|
67
|
+
await rollback(db, SPEC) # back to the state before the import (until purge)
|
|
68
|
+
await recover(db, SPEC) # on startup: cleans up an import that stopped half way
|
|
69
|
+
await purge(db, SPEC) # daily: drops the rollback copy after 7 days
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
How the swap works: documents go into `_import_<name>`, then each collection is renamed to
|
|
73
|
+
`_preimport_<name>` and the staging copy into its place. A journal (`_backup_journal`) records each step, so an
|
|
74
|
+
interrupted import is rolled back on the next start. File folders keep their old content in `.preimport/`.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
`pip install -e ".[dev]"`, then `pytest -q` and `ruff check .`. Engine tests use mongomock; with `MONGO_URL` set
|
|
79
|
+
they run against a real MongoDB (CI does both). `tests/vector.scaliobak` is the shared test bundle
|
|
80
|
+
(`tests/make_vector.py`); the age layer is checked against rage (`pyrage`) in both directions.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# scalio-backup
|
|
2
|
+
|
|
3
|
+
Encrypted export and import of a whole Scalio app instance, shared by the Scalio apps (Nexus first, then
|
|
4
|
+
Foundry, Rosetta, Citadel). Used for backup/restore and for moving an instance between self-hosted and SaaS.
|
|
5
|
+
Concept and decisions E1-E12: https://claude.ai/artifact/Ej8DFrgi4hcFDQ69dvKWZ7
|
|
6
|
+
|
|
7
|
+
Nothing in this package is secret. The password is the customer's; without it nobody (Scalio included) can
|
|
8
|
+
open a bundle.
|
|
9
|
+
|
|
10
|
+
## Bundle format (`.scaliobak`)
|
|
11
|
+
|
|
12
|
+
- Outside: standard [age v1](https://age-encryption.org/v1) with a passphrase (scrypt, work factor 2^18),
|
|
13
|
+
ChaCha20-Poly1305 in 64 KiB chunks. `age -d backup.scaliobak > backup.tar` opens it without Scalio.
|
|
14
|
+
- Inside: a tar stream. `manifest.json` first (format `scalio-backup/1`, product, app and schema version,
|
|
15
|
+
source, options, SHA-256 per part), then `collections/<name>.jsonl` (MongoDB Extended JSON, canonical, so
|
|
16
|
+
dates, Int64 and ObjectIds stay exact), `meta.jsonl`, and for file folders `files/<dir>.index.jsonl` plus the
|
|
17
|
+
files. The import checks every hash; a changed or incomplete bundle is refused before anything is written.
|
|
18
|
+
|
|
19
|
+
## Classes
|
|
20
|
+
|
|
21
|
+
Every collection (and every document of a mixed `meta` collection) has one class: `config` (always exported),
|
|
22
|
+
`data` (exported unless "configuration only"), `cache`, `ephemeral`, `instance` (never exported). Each app keeps
|
|
23
|
+
its classes in its own `backup_spec.py` and a test that fails on an unclassified collection.
|
|
24
|
+
|
|
25
|
+
## Use in an app
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from scalio_backup import AppSpec, Preserve, apply_import, export_instance, open_bundle, recover, rollback, purge
|
|
29
|
+
|
|
30
|
+
SPEC = AppSpec(product="scalio-nexus", app_version=VERSION, collections=..., meta_ids=...,
|
|
31
|
+
secret_fields={"settings": ("entra.clientSecret", ...)}, file_dirs={"hr": HR_UPLOAD_DIR},
|
|
32
|
+
schema_version=current_schema, export_hook=decrypt_instance_secrets, import_hook=encrypt_again)
|
|
33
|
+
|
|
34
|
+
# export (background job): writes the encrypted bundle to a file object
|
|
35
|
+
manifest = await export_instance(db, SPEC, out, password, include_data=True, include_secrets=True,
|
|
36
|
+
created_by=user_email, source={"mode": "selfhosted", "host": host, "tenant": tid})
|
|
37
|
+
|
|
38
|
+
# import, step 1: decrypt and verify into a work folder, nothing changes yet (WrongPassword, BundleError,
|
|
39
|
+
# IncompatibleBundle when the product differs or the bundle is newer than this instance)
|
|
40
|
+
bundle = open_bundle(upload, password, SPEC, target_schema=await current_schema(db))
|
|
41
|
+
show(bundle.preview()) # source, counts, warnings; the app adds its own (other tenant, license limits)
|
|
42
|
+
|
|
43
|
+
# import, step 2: inside the app's maintenance window (no writes, background jobs paused)
|
|
44
|
+
await apply_import(db, SPEC, bundle, preserve=(Preserve("app_users", {"email": me}),), imported_by=me)
|
|
45
|
+
bundle.cleanup()
|
|
46
|
+
# then: migrations, end all sessions, re-sync caches, show the checklist
|
|
47
|
+
|
|
48
|
+
await rollback(db, SPEC) # back to the state before the import (until purge)
|
|
49
|
+
await recover(db, SPEC) # on startup: cleans up an import that stopped half way
|
|
50
|
+
await purge(db, SPEC) # daily: drops the rollback copy after 7 days
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
How the swap works: documents go into `_import_<name>`, then each collection is renamed to
|
|
54
|
+
`_preimport_<name>` and the staging copy into its place. A journal (`_backup_journal`) records each step, so an
|
|
55
|
+
interrupted import is rolled back on the next start. File folders keep their old content in `.preimport/`.
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
`pip install -e ".[dev]"`, then `pytest -q` and `ruff check .`. Engine tests use mongomock; with `MONGO_URL` set
|
|
60
|
+
they run against a real MongoDB (CI does both). `tests/vector.scaliobak` is the shared test bundle
|
|
61
|
+
(`tests/make_vector.py`); the age layer is checked against rage (`pyrage`) in both directions.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scalio-backup"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Encrypted export and import (backup, restore, move) shared by the Scalio apps"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
dependencies = ["cryptography>=42", "pymongo>=4.6", "packaging>=23"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "mongomock-motor>=0.0.29", "typing-extensions>=4.8", "pyrage>=1.1", "motor>=3.3", "ruff>=0.6"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
asyncio_mode = "auto"
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
|
|
24
|
+
[tool.ruff]
|
|
25
|
+
line-length = 120
|
|
26
|
+
target-version = "py311"
|
|
27
|
+
|
|
28
|
+
[tool.ruff.lint]
|
|
29
|
+
select = ["E", "F", "W", "I", "B"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Encrypted export and import of a Scalio app instance (backup, restore, self-hosted <-> SaaS move)."""
|
|
2
|
+
from .age import AgeError, WrongPassword
|
|
3
|
+
from .engine import (
|
|
4
|
+
Bundle,
|
|
5
|
+
BundleError,
|
|
6
|
+
IncompatibleBundle,
|
|
7
|
+
apply_import,
|
|
8
|
+
blank_paths,
|
|
9
|
+
check_compatible,
|
|
10
|
+
check_password,
|
|
11
|
+
export_instance,
|
|
12
|
+
open_bundle,
|
|
13
|
+
purge,
|
|
14
|
+
recover,
|
|
15
|
+
rollback,
|
|
16
|
+
status,
|
|
17
|
+
)
|
|
18
|
+
from .spec import CACHE, CLASSES, CONFIG, DATA, EPHEMERAL, INSTANCE, AppSpec, Preserve
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__all__ = ["AgeError", "AppSpec", "Bundle", "BundleError", "CACHE", "CLASSES", "CONFIG", "DATA", "EPHEMERAL",
|
|
22
|
+
"INSTANCE", "IncompatibleBundle", "Preserve", "WrongPassword", "apply_import", "blank_paths",
|
|
23
|
+
"check_compatible", "check_password", "export_instance", "open_bundle", "purge", "recover",
|
|
24
|
+
"rollback", "status"]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""age v1 file format with a passphrase (scrypt recipient), streaming in both directions.
|
|
2
|
+
|
|
3
|
+
Spec: https://age-encryption.org/v1 . Files written here open with the `age` tool (`age -d file`) and files
|
|
4
|
+
from `age -p` open here, so a customer can always reach the data without Scalio. Only the scrypt
|
|
5
|
+
recipient is supported, and it must be the only stanza (as the spec requires).
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import InvalidTag
|
|
14
|
+
from cryptography.hazmat.primitives import hashes
|
|
15
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
16
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
17
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
18
|
+
|
|
19
|
+
INTRO = b"age-encryption.org/v1\n"
|
|
20
|
+
SCRYPT_LABEL = b"age-encryption.org/v1/scrypt"
|
|
21
|
+
CHUNK = 64 * 1024
|
|
22
|
+
TAG = 16
|
|
23
|
+
DEFAULT_LOG_N = 18 # same work factor as the age tool on a current machine (256 MiB, about 1 s)
|
|
24
|
+
MAX_LOG_N = 20 # refuse files that would make us spend more memory than that
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgeError(Exception):
|
|
28
|
+
"""The file is not a valid age file, or it was changed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WrongPassword(AgeError):
|
|
32
|
+
"""The passphrase does not open this file."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _b64(data: bytes) -> bytes:
|
|
36
|
+
return base64.b64encode(data).rstrip(b"=")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unb64(data: bytes) -> bytes:
|
|
40
|
+
if b"=" in data or b"\r" in data:
|
|
41
|
+
raise AgeError("bad base64 in header")
|
|
42
|
+
try:
|
|
43
|
+
out = base64.b64decode(data + b"=" * (-len(data) % 4), validate=True)
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
raise AgeError("bad base64 in header") from e
|
|
46
|
+
if _b64(out) != data:
|
|
47
|
+
raise AgeError("non-canonical base64 in header")
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _hkdf(ikm: bytes, salt: bytes, info: bytes) -> bytes:
|
|
52
|
+
return HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=info).derive(ikm)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _scrypt(password: bytes, salt: bytes, log_n: int) -> bytes:
|
|
56
|
+
return Scrypt(salt=SCRYPT_LABEL + salt, length=32, n=1 << log_n, r=8, p=1).derive(password)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _nonce(counter: int, last: bool) -> bytes:
|
|
60
|
+
if counter >= 1 << 88:
|
|
61
|
+
raise AgeError("file too large")
|
|
62
|
+
return counter.to_bytes(11, "big") + (b"\x01" if last else b"\x00")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _pw(password) -> bytes:
|
|
66
|
+
if isinstance(password, str):
|
|
67
|
+
password = password.encode("utf-8")
|
|
68
|
+
if not password:
|
|
69
|
+
raise ValueError("empty passphrase")
|
|
70
|
+
return password
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EncryptWriter(io.RawIOBase):
|
|
74
|
+
"""File-like object: write() plaintext, close() finishes the file. Writes the age file to `out`."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, out, password, log_n: int = DEFAULT_LOG_N):
|
|
77
|
+
super().__init__()
|
|
78
|
+
self._out = out
|
|
79
|
+
file_key = os.urandom(16)
|
|
80
|
+
salt = os.urandom(16)
|
|
81
|
+
wrap = ChaCha20Poly1305(_scrypt(_pw(password), salt, log_n)).encrypt(b"\x00" * 12, file_key, None)
|
|
82
|
+
header = INTRO + b"-> scrypt " + _b64(salt) + b" " + str(log_n).encode() + b"\n" + _b64(wrap) + b"\n---"
|
|
83
|
+
mac = hmac.new(_hkdf(file_key, b"", b"header"), header, hashlib.sha256).digest()
|
|
84
|
+
out.write(header + b" " + _b64(mac) + b"\n")
|
|
85
|
+
nonce = os.urandom(16)
|
|
86
|
+
out.write(nonce)
|
|
87
|
+
self._aead = ChaCha20Poly1305(_hkdf(file_key, nonce, b"payload"))
|
|
88
|
+
self._buf = bytearray()
|
|
89
|
+
self._counter = 0
|
|
90
|
+
self._done = False
|
|
91
|
+
|
|
92
|
+
def writable(self):
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def write(self, data) -> int:
|
|
96
|
+
if self._done:
|
|
97
|
+
raise ValueError("write to closed age writer")
|
|
98
|
+
self._buf += data
|
|
99
|
+
# keep at least one byte back: the last chunk is only known on close
|
|
100
|
+
while len(self._buf) > CHUNK:
|
|
101
|
+
self._emit(bytes(self._buf[:CHUNK]), last=False)
|
|
102
|
+
del self._buf[:CHUNK]
|
|
103
|
+
return len(data)
|
|
104
|
+
|
|
105
|
+
def _emit(self, chunk: bytes, last: bool):
|
|
106
|
+
self._out.write(self._aead.encrypt(_nonce(self._counter, last), chunk, None))
|
|
107
|
+
self._counter += 1
|
|
108
|
+
|
|
109
|
+
def close(self):
|
|
110
|
+
if not self._done:
|
|
111
|
+
self._emit(bytes(self._buf), last=True)
|
|
112
|
+
self._buf.clear()
|
|
113
|
+
self._done = True
|
|
114
|
+
super().close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_line(src, limit: int = 4096) -> bytes:
|
|
118
|
+
line = bytearray()
|
|
119
|
+
while True:
|
|
120
|
+
c = src.read(1)
|
|
121
|
+
if not c:
|
|
122
|
+
raise AgeError("unexpected end of header")
|
|
123
|
+
line += c
|
|
124
|
+
if c == b"\n":
|
|
125
|
+
return bytes(line)
|
|
126
|
+
if len(line) > limit:
|
|
127
|
+
raise AgeError("header line too long")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DecryptReader(io.RawIOBase):
|
|
131
|
+
"""File-like object over an age file: read() returns verified plaintext. A changed chunk raises AgeError
|
|
132
|
+
at the read that reaches it; nothing unverified is ever returned."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, src, password, max_log_n: int = MAX_LOG_N):
|
|
135
|
+
super().__init__()
|
|
136
|
+
self._src = src
|
|
137
|
+
if _read_line(src) != INTRO:
|
|
138
|
+
raise AgeError("not an age v1 file")
|
|
139
|
+
header = bytearray(INTRO)
|
|
140
|
+
stanzas = []
|
|
141
|
+
while True:
|
|
142
|
+
line = _read_line(src)
|
|
143
|
+
if line.startswith(b"---"):
|
|
144
|
+
break
|
|
145
|
+
if not line.startswith(b"-> "):
|
|
146
|
+
raise AgeError("bad header")
|
|
147
|
+
header += line
|
|
148
|
+
args = line[3:-1].split(b" ")
|
|
149
|
+
body = bytearray()
|
|
150
|
+
while True:
|
|
151
|
+
bl = _read_line(src)
|
|
152
|
+
header += bl
|
|
153
|
+
chunk = bl[:-1]
|
|
154
|
+
if len(chunk) > 64:
|
|
155
|
+
raise AgeError("bad stanza body")
|
|
156
|
+
body += chunk
|
|
157
|
+
if len(chunk) < 64:
|
|
158
|
+
break
|
|
159
|
+
stanzas.append((args, _unb64(bytes(body))))
|
|
160
|
+
if not line.startswith(b"--- ") or not line.endswith(b"\n"):
|
|
161
|
+
raise AgeError("bad header end")
|
|
162
|
+
header += b"---"
|
|
163
|
+
mac = _unb64(line[4:-1])
|
|
164
|
+
if len(stanzas) != 1 or stanzas[0][0][:1] != [b"scrypt"]:
|
|
165
|
+
raise AgeError("this file is not protected by a passphrase only")
|
|
166
|
+
args, body = stanzas[0]
|
|
167
|
+
if len(args) != 3:
|
|
168
|
+
raise AgeError("bad scrypt stanza")
|
|
169
|
+
salt = _unb64(args[1])
|
|
170
|
+
if len(salt) != 16 or not args[2].isdigit() or args[2].startswith(b"0") or len(body) != 32:
|
|
171
|
+
raise AgeError("bad scrypt stanza")
|
|
172
|
+
log_n = int(args[2])
|
|
173
|
+
if log_n > max_log_n:
|
|
174
|
+
raise AgeError(f"work factor 2^{log_n} is above the allowed 2^{max_log_n}")
|
|
175
|
+
try:
|
|
176
|
+
file_key = ChaCha20Poly1305(_scrypt(_pw(password), salt, log_n)).decrypt(b"\x00" * 12, body, None)
|
|
177
|
+
except InvalidTag as e:
|
|
178
|
+
raise WrongPassword("wrong password") from e
|
|
179
|
+
if not hmac.compare_digest(hmac.new(_hkdf(file_key, b"", b"header"), bytes(header), hashlib.sha256).digest(),
|
|
180
|
+
mac):
|
|
181
|
+
raise AgeError("header was changed")
|
|
182
|
+
nonce = src.read(16)
|
|
183
|
+
if len(nonce) != 16:
|
|
184
|
+
raise AgeError("file is truncated")
|
|
185
|
+
self._aead = ChaCha20Poly1305(_hkdf(file_key, nonce, b"payload"))
|
|
186
|
+
self._counter = 0
|
|
187
|
+
self._plain = b""
|
|
188
|
+
self._pos = 0
|
|
189
|
+
self._eof = False
|
|
190
|
+
self._next = src.read(CHUNK + TAG) # one chunk read ahead to know which is the last
|
|
191
|
+
|
|
192
|
+
def readable(self):
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def _decrypt_next(self):
|
|
196
|
+
cur = self._next
|
|
197
|
+
if not cur:
|
|
198
|
+
raise AgeError("file is truncated")
|
|
199
|
+
nxt = self._src.read(CHUNK + TAG) if len(cur) == CHUNK + TAG else b""
|
|
200
|
+
last = not nxt
|
|
201
|
+
try:
|
|
202
|
+
plain = self._aead.decrypt(_nonce(self._counter, last), cur, None)
|
|
203
|
+
except InvalidTag as e:
|
|
204
|
+
raise AgeError("file was changed or is truncated") from e
|
|
205
|
+
if last and not plain and self._counter > 0:
|
|
206
|
+
raise AgeError("empty last chunk")
|
|
207
|
+
self._counter += 1
|
|
208
|
+
self._next = nxt
|
|
209
|
+
self._eof = last
|
|
210
|
+
self._plain, self._pos = plain, 0
|
|
211
|
+
|
|
212
|
+
def readinto(self, b) -> int:
|
|
213
|
+
while self._pos >= len(self._plain):
|
|
214
|
+
if self._eof:
|
|
215
|
+
return 0
|
|
216
|
+
self._decrypt_next()
|
|
217
|
+
n = min(len(b), len(self._plain) - self._pos)
|
|
218
|
+
b[:n] = self._plain[self._pos:self._pos + n]
|
|
219
|
+
self._pos += n
|
|
220
|
+
return n
|
|
221
|
+
|
|
222
|
+
def read_all_and_verify(self):
|
|
223
|
+
"""Read to the end; raises AgeError if anything was changed."""
|
|
224
|
+
while self.read(CHUNK):
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def encrypt_bytes(data: bytes, password, log_n: int = DEFAULT_LOG_N) -> bytes:
|
|
229
|
+
out = io.BytesIO()
|
|
230
|
+
w = EncryptWriter(out, password, log_n)
|
|
231
|
+
w.write(data)
|
|
232
|
+
w.close()
|
|
233
|
+
return out.getvalue()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def decrypt_bytes(data: bytes, password, max_log_n: int = MAX_LOG_N) -> bytes:
|
|
237
|
+
return DecryptReader(io.BytesIO(data), password, max_log_n).read()
|