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.
@@ -0,0 +1,5 @@
1
+ /venv/
2
+ /*.mp4
3
+ /hello.py
4
+ *.pyc
5
+ /*.png
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
@@ -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'
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ #log_cli=true
3
+ log_level=NOTSET
4
+
@@ -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