stashfs 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.
- stashfs-0.1.0/.gitignore +5 -0
- stashfs-0.1.0/PKG-INFO +11 -0
- stashfs-0.1.0/README.md +65 -0
- stashfs-0.1.0/TODO.md +29 -0
- stashfs-0.1.0/pyproject.toml +86 -0
- stashfs-0.1.0/pytest.ini +4 -0
- stashfs-0.1.0/stashfs/__init__.py +80 -0
- stashfs-0.1.0/stashfs/cli.py +143 -0
- stashfs-0.1.0/stashfs/container.py +138 -0
- stashfs-0.1.0/stashfs/crypto.py +125 -0
- stashfs-0.1.0/stashfs/file_index.py +71 -0
- stashfs-0.1.0/stashfs/fuse_app.py +366 -0
- stashfs-0.1.0/stashfs/legacy_fs.py +101 -0
- stashfs-0.1.0/stashfs/optimize.py +190 -0
- stashfs-0.1.0/stashfs/slot_table.py +179 -0
- stashfs-0.1.0/stashfs/storage.py +212 -0
- stashfs-0.1.0/stashfs/volume.py +321 -0
- stashfs-0.1.0/tests/conftest.py +149 -0
- stashfs-0.1.0/tests/test_cli.py +76 -0
- stashfs-0.1.0/tests/test_container.py +166 -0
- stashfs-0.1.0/tests/test_crypto.py +116 -0
- stashfs-0.1.0/tests/test_optimize.py +214 -0
- stashfs-0.1.0/tests/test_realistic.py +175 -0
- stashfs-0.1.0/tests/test_slot_table.py +221 -0
- stashfs-0.1.0/tests/test_stash.py +456 -0
- stashfs-0.1.0/tests/test_storage.py +162 -0
- stashfs-0.1.0/tests/test_volume.py +360 -0
- stashfs-0.1.0/uv.lock +451 -0
stashfs-0.1.0/.gitignore
ADDED
stashfs-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stashfs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Encrypted single-file FUSE filesystem
|
|
5
|
+
Author: github.com/cybergrind
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Requires-Dist: argon2-cffi>=23.1.0
|
|
9
|
+
Requires-Dist: cryptography>=42
|
|
10
|
+
Requires-Dist: fuse-python==1.0.8
|
|
11
|
+
Requires-Dist: pytest==8.2.2
|
stashfs-0.1.0/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# stashfs
|
|
2
|
+
|
|
3
|
+
A tiny FUSE filesystem that stores its entire contents, encrypted, inside a
|
|
4
|
+
single regular file. One backing file can host up to **8 independent
|
|
5
|
+
volumes** -- one "no password" slot plus up to seven password-protected
|
|
6
|
+
slots -- each with its own private file listing.
|
|
7
|
+
|
|
8
|
+
(Previously named `fly`, then briefly `fyl`. The published PyPI name is
|
|
9
|
+
`stashfs`; `fly` is taken on PyPI and `fyl` tripped PyPI's
|
|
10
|
+
similar-name check.)
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv sync # install dependencies
|
|
16
|
+
uv run stashfs mount <backing> [mountpoint] [--ttl SECONDS] [--debug]
|
|
17
|
+
uv run stashfs optimize <backing> # reclaim space
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
On mount `stashfs` prompts for a password via `getpass`. The empty
|
|
21
|
+
string is a real, stable password that always maps to slot 0; any
|
|
22
|
+
non-empty password either unlocks its existing slot (slots 1..7) or
|
|
23
|
+
grabs the first free slot to start a new volume. A slot only becomes
|
|
24
|
+
"occupied" when the first file is written, and reverts to free when
|
|
25
|
+
the last file is removed. If every password slot is occupied and the
|
|
26
|
+
provided password matches none of them, the mount fails with
|
|
27
|
+
`password does not match`.
|
|
28
|
+
|
|
29
|
+
Unmount with `fusermount -u <mountpoint>`; `stashfs` also auto-unmounts
|
|
30
|
+
after `--ttl` seconds of idleness (default 300).
|
|
31
|
+
|
|
32
|
+
Every mutation appends fresh chunks (append-only layer for crash
|
|
33
|
+
safety) and never reclaims superseded ones, so the backing file
|
|
34
|
+
monotonically grows. `stashfs optimize` rebuilds the file with only the
|
|
35
|
+
live chunks of every unlocked slot; it must not be run while the file
|
|
36
|
+
is mounted.
|
|
37
|
+
|
|
38
|
+
## Security model
|
|
39
|
+
|
|
40
|
+
* **Cipher:** AES-256-GCM, 4 KiB plaintext per chunk, fresh random nonce
|
|
41
|
+
per chunk write. GCM's built-in tag authenticates every chunk.
|
|
42
|
+
* **Key derivation:** Argon2id(password, global_salt) -> master key, then
|
|
43
|
+
HKDF-SHA256 expands the master into a distinct per-slot key.
|
|
44
|
+
* **Slot table:** eight 80-byte slots at a fixed offset at the start of
|
|
45
|
+
the backing file. Each slot stores an encrypted wrap of
|
|
46
|
+
`(volume_key, file_table_chunk_id)` under its own per-slot key.
|
|
47
|
+
* **Leakage we accept:** the per-slot *occupancy flag* is plaintext, so
|
|
48
|
+
an attacker can observe how many volumes the container holds. This is
|
|
49
|
+
deliberate; it makes "pick the first free slot" easy and avoids
|
|
50
|
+
VeraCrypt-grade steganography tricks.
|
|
51
|
+
* **Not a production secrets store.** Compaction is available only
|
|
52
|
+
offline via `stashfs optimize`; no protection against physical memory
|
|
53
|
+
inspection, no multi-user keying.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv sync # create the venv
|
|
59
|
+
uv run pytest -q # run the whole suite
|
|
60
|
+
uv run pre-commit run --all-files # ruff + pyrefly
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The whole test suite stays under one second. Tests use
|
|
64
|
+
`KDFParams.fast()` to keep Argon2id cheap; production mounts use the
|
|
65
|
+
full Argon2id cost parameters.
|
stashfs-0.1.0/TODO.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
|
|
2
|
+
# modernize tests
|
|
3
|
+
|
|
4
|
+
use red/green TDD aproach to improve the project
|
|
5
|
+
|
|
6
|
+
we need some good fixtures that will facilitate further improvements
|
|
7
|
+
We should be able to test all operations fast (copy, add, remove, etc)
|
|
8
|
+
|
|
9
|
+
we should keep tests withing reasonable timings -- do not allow tests that run > 1 sec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# support most of operations
|
|
13
|
+
|
|
14
|
+
copy - should be inplace, probably we need to separate file names into some kind of table
|
|
15
|
+
remove
|
|
16
|
+
|
|
17
|
+
TODO: what operations are currently are currently not supported?
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# add crypto support
|
|
21
|
+
|
|
22
|
+
allow passwords, use symmetric algo, probably `AES-128-CBC`
|
|
23
|
+
if password is not ours - we should use new storage - so with different passwords we can see different files
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = 'stashfs'
|
|
3
|
+
version = '0.1.0'
|
|
4
|
+
description = 'Encrypted single-file FUSE filesystem'
|
|
5
|
+
authors = [{ name = 'github.com/cybergrind' }]
|
|
6
|
+
license = { text = 'Apache-2.0' }
|
|
7
|
+
requires-python = '>=3.13'
|
|
8
|
+
dependencies = [
|
|
9
|
+
'argon2-cffi>=23.1.0',
|
|
10
|
+
'cryptography>=42',
|
|
11
|
+
'fuse-python==1.0.8',
|
|
12
|
+
'pytest==8.2.2',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
stashfs = 'stashfs.cli:main'
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ['hatchling']
|
|
20
|
+
build-backend = 'hatchling.build'
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ['stashfs']
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.sdist]
|
|
26
|
+
exclude = [
|
|
27
|
+
'/.*',
|
|
28
|
+
'/*.png',
|
|
29
|
+
'/CLAUDE.md',
|
|
30
|
+
'/requirements.txt',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# installed with: uv sync
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = ['pre-commit>=4.5.1', 'pyrefly>=0.59', 'ruff', 'uv>=0.11.1']
|
|
36
|
+
|
|
37
|
+
[tool.pyrefly]
|
|
38
|
+
# fuse-python ships no type stubs and pyrefly cannot introspect its C
|
|
39
|
+
# extension. Everything else is resolvable in the project venv.
|
|
40
|
+
ignore-missing-imports = ['fuse']
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 120
|
|
44
|
+
target-version = 'py314'
|
|
45
|
+
indent-width = 4
|
|
46
|
+
exclude = ['.venv', '.git']
|
|
47
|
+
|
|
48
|
+
[tool.ruff.format]
|
|
49
|
+
quote-style = 'single'
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint.isort]
|
|
52
|
+
combine-as-imports = true
|
|
53
|
+
known-first-party = ['snapshot_manager']
|
|
54
|
+
lines-after-imports = 2
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
ignore = [
|
|
58
|
+
'T201', # print
|
|
59
|
+
'G004', # logging format
|
|
60
|
+
'Q000', # quotes
|
|
61
|
+
'Q001', # quotes
|
|
62
|
+
'Q003', # quotes
|
|
63
|
+
]
|
|
64
|
+
fixable = ['ALL']
|
|
65
|
+
select = [
|
|
66
|
+
'E', # pycodestyle
|
|
67
|
+
'F', # pyflakes
|
|
68
|
+
'I', # isort
|
|
69
|
+
'B', # flake8-bugbear
|
|
70
|
+
'UP', # pyupgrade
|
|
71
|
+
'C4', # flake8-comprehensions
|
|
72
|
+
'SIM', # flake8-simplify
|
|
73
|
+
'G', # flake8-logging-format
|
|
74
|
+
'ASYNC', # flake8-async
|
|
75
|
+
'PIE', # flake8-pie
|
|
76
|
+
'T20', # flake8-print
|
|
77
|
+
'PT', # flake8-pytest-style
|
|
78
|
+
'Q', # flake8-quotes
|
|
79
|
+
'RUF',
|
|
80
|
+
]
|
|
81
|
+
exclude = []
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint.flake8-quotes]
|
|
84
|
+
docstring-quotes = 'double'
|
|
85
|
+
inline-quotes = 'single'
|
|
86
|
+
multiline-quotes = 'single'
|
stashfs-0.1.0/pytest.ini
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""stashfs package - FUSE-based single-file filesystem (formerly ``fly`` / ``fyl``).
|
|
2
|
+
|
|
3
|
+
Public API is re-exported here so callers and tests can continue to do
|
|
4
|
+
``from stashfs import ...``. Implementation is split across submodules to
|
|
5
|
+
leave room for the crypto stack.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from stashfs.container import (
|
|
9
|
+
CHUNK_FRAME_SIZE,
|
|
10
|
+
CHUNK_PAYLOAD_SIZE,
|
|
11
|
+
DATA_START,
|
|
12
|
+
HEADER_SIZE,
|
|
13
|
+
N_SLOTS,
|
|
14
|
+
SLOT_SIZE,
|
|
15
|
+
SLOT_TABLE_SIZE,
|
|
16
|
+
Container,
|
|
17
|
+
ContainerCorrupt,
|
|
18
|
+
)
|
|
19
|
+
from stashfs.crypto import KDF, KEY_SIZE, NONCE_SIZE, TAG_SIZE, AEADChunk, KDFParams
|
|
20
|
+
from stashfs.file_index import FileIndexCorrupt, VolumeFile
|
|
21
|
+
from stashfs.fuse_app import (
|
|
22
|
+
TIME_PAT,
|
|
23
|
+
MyStat,
|
|
24
|
+
Stash,
|
|
25
|
+
auto_unmount,
|
|
26
|
+
call_fuse_exit,
|
|
27
|
+
log,
|
|
28
|
+
main,
|
|
29
|
+
mount,
|
|
30
|
+
parse_args,
|
|
31
|
+
update_log_level,
|
|
32
|
+
)
|
|
33
|
+
from stashfs.legacy_fs import MAGIC_BYTES, FileRecord, FileStructure
|
|
34
|
+
from stashfs.slot_table import FLAG_FREE, FLAG_OCCUPIED, PasswordDoesNotMatch, SlotInfo, SlotTable
|
|
35
|
+
from stashfs.storage import CoverStorage, FileWrapper, Storage
|
|
36
|
+
from stashfs.volume import Volume, VolumeCorrupt
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
'CHUNK_FRAME_SIZE',
|
|
41
|
+
'CHUNK_PAYLOAD_SIZE',
|
|
42
|
+
'DATA_START',
|
|
43
|
+
'FLAG_FREE',
|
|
44
|
+
'FLAG_OCCUPIED',
|
|
45
|
+
'HEADER_SIZE',
|
|
46
|
+
'KDF',
|
|
47
|
+
'KEY_SIZE',
|
|
48
|
+
'MAGIC_BYTES',
|
|
49
|
+
'NONCE_SIZE',
|
|
50
|
+
'N_SLOTS',
|
|
51
|
+
'SLOT_SIZE',
|
|
52
|
+
'SLOT_TABLE_SIZE',
|
|
53
|
+
'TAG_SIZE',
|
|
54
|
+
'TIME_PAT',
|
|
55
|
+
'AEADChunk',
|
|
56
|
+
'Container',
|
|
57
|
+
'ContainerCorrupt',
|
|
58
|
+
'CoverStorage',
|
|
59
|
+
'FileIndexCorrupt',
|
|
60
|
+
'FileRecord',
|
|
61
|
+
'FileStructure',
|
|
62
|
+
'FileWrapper',
|
|
63
|
+
'KDFParams',
|
|
64
|
+
'MyStat',
|
|
65
|
+
'PasswordDoesNotMatch',
|
|
66
|
+
'SlotInfo',
|
|
67
|
+
'SlotTable',
|
|
68
|
+
'Stash',
|
|
69
|
+
'Storage',
|
|
70
|
+
'Volume',
|
|
71
|
+
'VolumeCorrupt',
|
|
72
|
+
'VolumeFile',
|
|
73
|
+
'auto_unmount',
|
|
74
|
+
'call_fuse_exit',
|
|
75
|
+
'log',
|
|
76
|
+
'main',
|
|
77
|
+
'mount',
|
|
78
|
+
'parse_args',
|
|
79
|
+
'update_log_level',
|
|
80
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Unified ``stashfs`` command-line entry point.
|
|
2
|
+
|
|
3
|
+
Exposes two subcommands:
|
|
4
|
+
|
|
5
|
+
* ``stashfs mount <backing> [mountpoint]`` — mount the FUSE filesystem.
|
|
6
|
+
* ``stashfs optimize <backing>`` — rebuild the backing file, reclaiming
|
|
7
|
+
space left behind by deletions, overwrites, and renames.
|
|
8
|
+
|
|
9
|
+
Installed as a console script via ``[project.scripts]`` so users can
|
|
10
|
+
run it as ``stashfs ...`` after ``uv tool install .``.
|
|
11
|
+
|
|
12
|
+
A bare path to an existing file (``stashfs /path/to/backing``) is treated
|
|
13
|
+
as shorthand for ``stashfs mount /path/to/backing`` — mounting is the
|
|
14
|
+
overwhelmingly common case and typing ``mount`` every time is friction.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import getpass
|
|
21
|
+
import logging
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from stashfs.crypto import KDF
|
|
26
|
+
from stashfs.fuse_app import _configure_logging, run_mount
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger('stashfs.cli')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(prog='stashfs', description='Encrypted single-file FUSE filesystem')
|
|
34
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
35
|
+
|
|
36
|
+
mount_p = sub.add_parser('mount', help='Mount the filesystem')
|
|
37
|
+
mount_p.add_argument('fname', type=lambda x: Path(x).resolve())
|
|
38
|
+
mount_p.add_argument('mountpoint', nargs='?', default='/tmp/aaa', type=Path)
|
|
39
|
+
mount_p.add_argument('--ttl', type=int, default=300)
|
|
40
|
+
mount_p.add_argument('--debug', action='store_true')
|
|
41
|
+
|
|
42
|
+
opt_p = sub.add_parser('optimize', help='Rebuild the backing file to reclaim space')
|
|
43
|
+
opt_p.add_argument('fname', type=lambda x: Path(x).resolve())
|
|
44
|
+
opt_p.add_argument(
|
|
45
|
+
'--password',
|
|
46
|
+
action='append',
|
|
47
|
+
default=[],
|
|
48
|
+
help='Password for an occupied slot (repeatable). Omit to be prompted interactively.',
|
|
49
|
+
)
|
|
50
|
+
opt_p.add_argument(
|
|
51
|
+
'--drop-locked',
|
|
52
|
+
action='store_true',
|
|
53
|
+
help='Dangerous: purge any occupied slot whose password is unknown.',
|
|
54
|
+
)
|
|
55
|
+
opt_p.add_argument('--debug', action='store_true')
|
|
56
|
+
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_SUBCOMMANDS = frozenset({'mount', 'optimize'})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _inject_implicit_mount(argv: list[str] | None) -> list[str] | None:
|
|
64
|
+
"""If the user typed ``stashfs <existing-file>``, prepend ``mount``.
|
|
65
|
+
|
|
66
|
+
We only kick in when the first positional argument is neither a
|
|
67
|
+
known subcommand nor a help/option flag, and it points at an
|
|
68
|
+
existing filesystem path. Anything else falls through to argparse
|
|
69
|
+
untouched so error messages stay accurate.
|
|
70
|
+
"""
|
|
71
|
+
if argv is None:
|
|
72
|
+
argv = sys.argv[1:]
|
|
73
|
+
if not argv:
|
|
74
|
+
return argv
|
|
75
|
+
first = argv[0]
|
|
76
|
+
if first in _SUBCOMMANDS or first.startswith('-'):
|
|
77
|
+
return argv
|
|
78
|
+
if Path(first).exists():
|
|
79
|
+
return ['mount', *argv]
|
|
80
|
+
return argv
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: list[str] | None = None) -> int:
|
|
84
|
+
parser = build_parser()
|
|
85
|
+
args = parser.parse_args(_inject_implicit_mount(argv))
|
|
86
|
+
_configure_logging(getattr(args, 'debug', False))
|
|
87
|
+
|
|
88
|
+
if args.command == 'mount':
|
|
89
|
+
return _run_mount(args)
|
|
90
|
+
if args.command == 'optimize':
|
|
91
|
+
return _run_optimize(args)
|
|
92
|
+
parser.error(f'unknown command {args.command!r}')
|
|
93
|
+
return 2
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_kdf(_args: argparse.Namespace) -> KDF:
|
|
97
|
+
"""Seam for tests to inject a faster KDF."""
|
|
98
|
+
return KDF()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _run_mount(args: argparse.Namespace) -> int:
|
|
102
|
+
run_mount(args)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _run_optimize(args: argparse.Namespace) -> int:
|
|
107
|
+
from stashfs.optimize import OptimizeError, optimize
|
|
108
|
+
|
|
109
|
+
if not args.fname.exists():
|
|
110
|
+
print(f'error: {args.fname} does not exist', file=sys.stderr)
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
passwords = list(args.password)
|
|
114
|
+
if not passwords:
|
|
115
|
+
# Interactive: prompt once for each likely slot. Users can press
|
|
116
|
+
# enter to stop adding passwords (empty string is always a
|
|
117
|
+
# valid slot-0 password).
|
|
118
|
+
while True:
|
|
119
|
+
pw = getpass.getpass('Password (enter on empty line to finish): ')
|
|
120
|
+
if pw == '' and passwords:
|
|
121
|
+
break
|
|
122
|
+
passwords.append(pw)
|
|
123
|
+
if pw == '':
|
|
124
|
+
# The user entered empty as their FIRST password; treat
|
|
125
|
+
# that as "try empty-slot only".
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
report = optimize(args.fname, passwords, kdf=_build_kdf(args), drop_locked=args.drop_locked)
|
|
130
|
+
except OptimizeError as exc:
|
|
131
|
+
print(f'error: {exc}', file=sys.stderr)
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
print(
|
|
135
|
+
f'optimize: {args.fname} {report.old_size} -> {report.new_size} bytes '
|
|
136
|
+
f'(reclaimed {report.reclaimed}), slots rebuilt={report.rebuilt_slots} '
|
|
137
|
+
f'dropped={report.dropped_slots}'
|
|
138
|
+
)
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == '__main__':
|
|
143
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Fixed-layout container over a ``Storage``.
|
|
2
|
+
|
|
3
|
+
The container owns the on-disk layout decisions that are *not* crypto:
|
|
4
|
+
where the header lives, how big a slot is, how big a chunk frame is,
|
|
5
|
+
and how chunks are addressed. The ``Volume`` layer will stack crypto on
|
|
6
|
+
top; the ``SlotTable`` layer will interpret the 768-byte slot blob.
|
|
7
|
+
|
|
8
|
+
Layout (offsets in bytes)::
|
|
9
|
+
|
|
10
|
+
0 : 16B global_salt (random, fed to Argon2id)
|
|
11
|
+
16 : 640B slot_table (8 slots x 80B)
|
|
12
|
+
656 : chunk[0]
|
|
13
|
+
4780 : chunk[1]
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
A chunk frame is always exactly ``CHUNK_FRAME_SIZE`` bytes: Volume will
|
|
17
|
+
interpret it as ``12B nonce || 4096B ciphertext || 16B tag``. The
|
|
18
|
+
container does not decode the frame; it just stores and retrieves them.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from stashfs.storage import FileWrapper, Storage
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
HEADER_SIZE = 16
|
|
30
|
+
SLOT_SIZE = 80
|
|
31
|
+
N_SLOTS = 8
|
|
32
|
+
SLOT_TABLE_SIZE = SLOT_SIZE * N_SLOTS
|
|
33
|
+
|
|
34
|
+
CHUNK_PAYLOAD_SIZE = 4096
|
|
35
|
+
CHUNK_FRAME_SIZE = CHUNK_PAYLOAD_SIZE + 12 + 16 # nonce + ciphertext + tag
|
|
36
|
+
|
|
37
|
+
DATA_START = HEADER_SIZE + SLOT_TABLE_SIZE
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ContainerCorrupt(Exception):
|
|
41
|
+
"""Raised when the backing storage cannot be interpreted as a container."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Container:
|
|
45
|
+
"""Framed chunk store over any ``Storage``.
|
|
46
|
+
|
|
47
|
+
On first use with an empty backing, the container writes a random
|
|
48
|
+
header and slot table. The caller (typically ``Volume``) is
|
|
49
|
+
responsible for populating the slot table with real cryptographic
|
|
50
|
+
wrappings once a password-protected volume is actually in use.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, storage: Storage) -> None:
|
|
54
|
+
self._storage = storage
|
|
55
|
+
self._ensure_initialised()
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def open_path(cls, path: Path) -> Container:
|
|
59
|
+
"""Convenience: wrap a path in a ``FileWrapper`` and open it."""
|
|
60
|
+
return cls(FileWrapper(path))
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def storage(self) -> Storage:
|
|
64
|
+
return self._storage
|
|
65
|
+
|
|
66
|
+
def _ensure_initialised(self) -> None:
|
|
67
|
+
current = self._storage.size()
|
|
68
|
+
if current == 0:
|
|
69
|
+
# Header + slot table start as uniform random bytes so free
|
|
70
|
+
# slots don't look structured on disk. We then deterministically
|
|
71
|
+
# clear the first byte of every slot to 0x00, because that byte
|
|
72
|
+
# is the free/occupied flag and must not accidentally read as
|
|
73
|
+
# 0x01 when the slot is actually free.
|
|
74
|
+
blob = bytearray(os.urandom(HEADER_SIZE + SLOT_TABLE_SIZE))
|
|
75
|
+
for i in range(N_SLOTS):
|
|
76
|
+
blob[HEADER_SIZE + i * SLOT_SIZE] = 0x00
|
|
77
|
+
self._storage.write_end(bytes(blob))
|
|
78
|
+
return
|
|
79
|
+
if current < DATA_START:
|
|
80
|
+
raise ContainerCorrupt(f'backing is {current} bytes, need at least {DATA_START} for header+slot_table')
|
|
81
|
+
tail = (current - DATA_START) % CHUNK_FRAME_SIZE
|
|
82
|
+
if tail != 0:
|
|
83
|
+
raise ContainerCorrupt(f'chunk region is not a multiple of {CHUNK_FRAME_SIZE} (extra {tail} bytes)')
|
|
84
|
+
|
|
85
|
+
def read_header(self) -> bytes:
|
|
86
|
+
return self._storage.read(HEADER_SIZE, 0)
|
|
87
|
+
|
|
88
|
+
def write_header(self, header: bytes) -> None:
|
|
89
|
+
if len(header) != HEADER_SIZE:
|
|
90
|
+
raise ValueError(f'header must be {HEADER_SIZE} bytes')
|
|
91
|
+
self._storage.write(0, header)
|
|
92
|
+
|
|
93
|
+
def read_slot_table(self) -> bytes:
|
|
94
|
+
return self._storage.read(SLOT_TABLE_SIZE, HEADER_SIZE)
|
|
95
|
+
|
|
96
|
+
def write_slot_table(self, blob: bytes) -> None:
|
|
97
|
+
if len(blob) != SLOT_TABLE_SIZE:
|
|
98
|
+
raise ValueError(f'slot_table must be {SLOT_TABLE_SIZE} bytes')
|
|
99
|
+
self._storage.write(HEADER_SIZE, blob)
|
|
100
|
+
|
|
101
|
+
def read_slot(self, index: int) -> bytes:
|
|
102
|
+
self._check_slot_index(index)
|
|
103
|
+
return self._storage.read(SLOT_SIZE, HEADER_SIZE + index * SLOT_SIZE)
|
|
104
|
+
|
|
105
|
+
def write_slot(self, index: int, blob: bytes) -> None:
|
|
106
|
+
self._check_slot_index(index)
|
|
107
|
+
if len(blob) != SLOT_SIZE:
|
|
108
|
+
raise ValueError(f'slot must be {SLOT_SIZE} bytes')
|
|
109
|
+
self._storage.write(HEADER_SIZE + index * SLOT_SIZE, blob)
|
|
110
|
+
|
|
111
|
+
def num_chunks(self) -> int:
|
|
112
|
+
return (self._storage.size() - DATA_START) // CHUNK_FRAME_SIZE
|
|
113
|
+
|
|
114
|
+
def read_chunk(self, index: int) -> bytes:
|
|
115
|
+
self._check_chunk_index(index)
|
|
116
|
+
return self._storage.read(CHUNK_FRAME_SIZE, DATA_START + index * CHUNK_FRAME_SIZE)
|
|
117
|
+
|
|
118
|
+
def write_chunk(self, index: int, frame: bytes) -> None:
|
|
119
|
+
self._check_chunk_index(index)
|
|
120
|
+
if len(frame) != CHUNK_FRAME_SIZE:
|
|
121
|
+
raise ValueError(f'chunk frame must be {CHUNK_FRAME_SIZE} bytes')
|
|
122
|
+
self._storage.write(DATA_START + index * CHUNK_FRAME_SIZE, frame)
|
|
123
|
+
|
|
124
|
+
def append_chunk(self, frame: bytes) -> int:
|
|
125
|
+
if len(frame) != CHUNK_FRAME_SIZE:
|
|
126
|
+
raise ValueError(f'chunk frame must be {CHUNK_FRAME_SIZE} bytes')
|
|
127
|
+
index = self.num_chunks()
|
|
128
|
+
self._storage.write_end(frame)
|
|
129
|
+
return index
|
|
130
|
+
|
|
131
|
+
def _check_slot_index(self, index: int) -> None:
|
|
132
|
+
if not 0 <= index < N_SLOTS:
|
|
133
|
+
raise IndexError(f'slot index {index} out of range [0, {N_SLOTS})')
|
|
134
|
+
|
|
135
|
+
def _check_chunk_index(self, index: int) -> None:
|
|
136
|
+
total = self.num_chunks()
|
|
137
|
+
if not 0 <= index < total:
|
|
138
|
+
raise IndexError(f'chunk index {index} out of range [0, {total})')
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Crypto primitives for the encrypted container.
|
|
2
|
+
|
|
3
|
+
Two small, independently testable pieces:
|
|
4
|
+
|
|
5
|
+
* ``KDF`` -- Argon2id for the slow password -> master key step,
|
|
6
|
+
HKDF-SHA256 for the fast master -> per-slot key step.
|
|
7
|
+
* ``AEADChunk`` -- AES-256-GCM seal/open with nonce + tag framed inline.
|
|
8
|
+
|
|
9
|
+
We use AES-256-GCM (32-byte keys) throughout. The plan document mentions
|
|
10
|
+
"AES-128-GCM" in one bullet but the same bullet also specifies a 32-byte
|
|
11
|
+
volume key; AES-256 is the consistent choice and gives us extra margin
|
|
12
|
+
for free.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import struct
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from argon2.low_level import Type, hash_secret_raw
|
|
22
|
+
from cryptography.exceptions import InvalidTag
|
|
23
|
+
from cryptography.hazmat.primitives import hashes
|
|
24
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
25
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
KEY_SIZE = 32
|
|
29
|
+
NONCE_SIZE = 12
|
|
30
|
+
TAG_SIZE = 16
|
|
31
|
+
|
|
32
|
+
HKDF_INFO_PREFIX = b'stashfs/slot/'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class KDFParams:
|
|
37
|
+
"""Argon2id cost parameters.
|
|
38
|
+
|
|
39
|
+
Production defaults are conservative; tests use the cheaper preset
|
|
40
|
+
via ``KDFParams.fast()`` so the whole suite stays well under its
|
|
41
|
+
per-test time budget.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
time_cost: int = 3
|
|
45
|
+
memory_cost: int = 64 * 1024 # 64 MiB
|
|
46
|
+
parallelism: int = 1
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def fast(cls) -> KDFParams:
|
|
50
|
+
return cls(time_cost=1, memory_cost=8 * 1024, parallelism=1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KDF:
|
|
54
|
+
"""Password -> master key -> per-slot key pipeline."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, params: KDFParams | None = None) -> None:
|
|
57
|
+
self.params = params or KDFParams()
|
|
58
|
+
|
|
59
|
+
def master(self, password: bytes | str, salt: bytes) -> bytes:
|
|
60
|
+
"""Argon2id(password, salt) -> ``KEY_SIZE`` bytes.
|
|
61
|
+
|
|
62
|
+
The empty password is a valid, stable input; callers upstream use
|
|
63
|
+
it deliberately for the slot-0 "no password" volume.
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(password, str):
|
|
66
|
+
password = password.encode('utf-8')
|
|
67
|
+
return hash_secret_raw(
|
|
68
|
+
secret=password,
|
|
69
|
+
salt=salt,
|
|
70
|
+
time_cost=self.params.time_cost,
|
|
71
|
+
memory_cost=self.params.memory_cost,
|
|
72
|
+
parallelism=self.params.parallelism,
|
|
73
|
+
hash_len=KEY_SIZE,
|
|
74
|
+
type=Type.ID,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def derive_slot(master_key: bytes, slot_index: int, out_len: int = KEY_SIZE) -> bytes:
|
|
79
|
+
"""HKDF-SHA256 expand master_key into a per-slot key.
|
|
80
|
+
|
|
81
|
+
The ``info`` tag binds the key to a slot index so different slots
|
|
82
|
+
never share a derived key even under the same master.
|
|
83
|
+
"""
|
|
84
|
+
info = HKDF_INFO_PREFIX + struct.pack('>I', slot_index)
|
|
85
|
+
hkdf = HKDF(
|
|
86
|
+
algorithm=hashes.SHA256(),
|
|
87
|
+
length=out_len,
|
|
88
|
+
salt=None,
|
|
89
|
+
info=info,
|
|
90
|
+
)
|
|
91
|
+
return hkdf.derive(master_key)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AEADChunk:
|
|
95
|
+
"""AES-256-GCM seal/open with nonce + tag packed into the frame."""
|
|
96
|
+
|
|
97
|
+
NONCE_SIZE = NONCE_SIZE
|
|
98
|
+
TAG_SIZE = TAG_SIZE
|
|
99
|
+
|
|
100
|
+
def __init__(self, key: bytes) -> None:
|
|
101
|
+
if len(key) != KEY_SIZE:
|
|
102
|
+
raise ValueError(f'key must be {KEY_SIZE} bytes, got {len(key)}')
|
|
103
|
+
self._aead = AESGCM(key)
|
|
104
|
+
|
|
105
|
+
def seal(self, plaintext: bytes, associated_data: bytes | None = None) -> bytes:
|
|
106
|
+
"""Encrypt ``plaintext``. Return ``nonce || ciphertext || tag``."""
|
|
107
|
+
nonce = os.urandom(NONCE_SIZE)
|
|
108
|
+
ct_and_tag = self._aead.encrypt(nonce, plaintext, associated_data)
|
|
109
|
+
return nonce + ct_and_tag
|
|
110
|
+
|
|
111
|
+
def open(self, frame: bytes, associated_data: bytes | None = None) -> bytes | None:
|
|
112
|
+
"""Decrypt a sealed frame. Return plaintext or ``None`` on auth failure."""
|
|
113
|
+
if len(frame) < NONCE_SIZE + TAG_SIZE:
|
|
114
|
+
return None
|
|
115
|
+
nonce = frame[:NONCE_SIZE]
|
|
116
|
+
ct_and_tag = frame[NONCE_SIZE:]
|
|
117
|
+
try:
|
|
118
|
+
return self._aead.decrypt(nonce, ct_and_tag, associated_data)
|
|
119
|
+
except InvalidTag:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def frame_overhead() -> int:
|
|
124
|
+
"""Bytes added on top of plaintext length when sealing."""
|
|
125
|
+
return NONCE_SIZE + TAG_SIZE
|