stashfs 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.
- stashfs/__init__.py +80 -0
- stashfs/cli.py +143 -0
- stashfs/container.py +138 -0
- stashfs/crypto.py +125 -0
- stashfs/file_index.py +71 -0
- stashfs/fuse_app.py +366 -0
- stashfs/legacy_fs.py +101 -0
- stashfs/optimize.py +190 -0
- stashfs/slot_table.py +179 -0
- stashfs/storage.py +212 -0
- stashfs/volume.py +321 -0
- stashfs-0.1.0.dist-info/METADATA +11 -0
- stashfs-0.1.0.dist-info/RECORD +15 -0
- stashfs-0.1.0.dist-info/WHEEL +4 -0
- stashfs-0.1.0.dist-info/entry_points.txt +2 -0
stashfs/__init__.py
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
|
+
]
|
stashfs/cli.py
ADDED
|
@@ -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())
|
stashfs/container.py
ADDED
|
@@ -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})')
|
stashfs/crypto.py
ADDED
|
@@ -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
|
stashfs/file_index.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Serialisable per-volume file index.
|
|
2
|
+
|
|
3
|
+
Replaces the byte-offset-based ``FileStructure`` from the legacy layout
|
|
4
|
+
with a chunk-id-based one. Each ``VolumeFile`` records the ordered list
|
|
5
|
+
of chunk ids that hold its plaintext data, plus the logical size so we
|
|
6
|
+
know how much of the last chunk is live.
|
|
7
|
+
|
|
8
|
+
Serialisation format (all integers big-endian)::
|
|
9
|
+
|
|
10
|
+
u32 num_files
|
|
11
|
+
repeat num_files times:
|
|
12
|
+
u32 name_length
|
|
13
|
+
bytes name (utf-8)
|
|
14
|
+
u64 size
|
|
15
|
+
u32 num_chunks
|
|
16
|
+
u64 * num_chunks chunk_ids
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import struct
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class VolumeFile:
|
|
27
|
+
name: str
|
|
28
|
+
size: int = 0
|
|
29
|
+
chunk_ids: list[int] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FileIndexCorrupt(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def serialize(files: dict[str, VolumeFile]) -> bytes:
|
|
37
|
+
out = bytearray()
|
|
38
|
+
out.extend(struct.pack('>I', len(files)))
|
|
39
|
+
for name in sorted(files):
|
|
40
|
+
vf = files[name]
|
|
41
|
+
encoded = vf.name.encode('utf-8')
|
|
42
|
+
out.extend(struct.pack('>I', len(encoded)))
|
|
43
|
+
out.extend(encoded)
|
|
44
|
+
out.extend(struct.pack('>Q', vf.size))
|
|
45
|
+
out.extend(struct.pack('>I', len(vf.chunk_ids)))
|
|
46
|
+
for cid in vf.chunk_ids:
|
|
47
|
+
out.extend(struct.pack('>Q', cid))
|
|
48
|
+
return bytes(out)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse(blob: bytes) -> dict[str, VolumeFile]:
|
|
52
|
+
try:
|
|
53
|
+
pos = 0
|
|
54
|
+
(num_files,) = struct.unpack_from('>I', blob, pos)
|
|
55
|
+
pos += 4
|
|
56
|
+
files: dict[str, VolumeFile] = {}
|
|
57
|
+
for _ in range(num_files):
|
|
58
|
+
(name_len,) = struct.unpack_from('>I', blob, pos)
|
|
59
|
+
pos += 4
|
|
60
|
+
name = blob[pos : pos + name_len].decode('utf-8')
|
|
61
|
+
pos += name_len
|
|
62
|
+
(size,) = struct.unpack_from('>Q', blob, pos)
|
|
63
|
+
pos += 8
|
|
64
|
+
(num_chunks,) = struct.unpack_from('>I', blob, pos)
|
|
65
|
+
pos += 4
|
|
66
|
+
chunk_ids = list(struct.unpack_from(f'>{num_chunks}Q', blob, pos))
|
|
67
|
+
pos += 8 * num_chunks
|
|
68
|
+
files[name] = VolumeFile(name=name, size=size, chunk_ids=chunk_ids)
|
|
69
|
+
return files
|
|
70
|
+
except struct.error as e:
|
|
71
|
+
raise FileIndexCorrupt(str(e)) from e
|