magicicapsula 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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from magicicapsula.cli import main
2
+
3
+ main()
@@ -0,0 +1,27 @@
1
+
2
+ ▓▓▓▓▓▓▓▓▓▓▓░░░░░
3
+ ▓▓▓ ▓ ░░
4
+ ▓▓ ▓ ░░░ ░░
5
+ ▓ ▓ ▓ ░▓▓▓ ░
6
+ ▓▓ ▓ ▓ ▓ ░▓▓░▓▓ ░
7
+ ▓ ▓ ▓ ▓▓ ▓░░░▓ ░
8
+ ▓ ▓ ▓ ▓█▒ ▓▓░▓▓ ░
9
+ ▓ ▓ ▓▓█▒ ▓▓▓ ░
10
+ ▓ ▓█▒█▒ ░
11
+ ▓ ▓ ▓ ▓█▒█▒ ░░ ░
12
+ ▓ ▓ ▓ ▓▒█▒█▒ ░░ ░
13
+ ▓ ▓ ▓ ▓▒█▒█▒ ░░░░░░
14
+ ▓ ▓ ▓ ▓█▒█▒█▒ ░
15
+ ▓ ▓▓░░░░░░░░░░░░░░░░░
16
+ ▓ ▓▓ ░▓░ ░▓░ ░▓░
17
+ ▓ ▓▓▓ ░▓░ ░▓░ ░▓░
18
+ ▓▓ ░▓▓░░▓▓░░▓▓░
19
+ ░▓▓░░▓▓░░▓▓░
20
+ ░░ ░░ ░░
21
+
22
+
23
+
24
+
25
+
26
+
27
+
magicicapsula/cli.py ADDED
@@ -0,0 +1,38 @@
1
+ import argparse
2
+ import importlib
3
+ import pkgutil
4
+ import sys
5
+
6
+ from magicicapsula import commands
7
+ from magicicapsula.commands import _style
8
+ from magicicapsula.core.errors import CapsuleError
9
+
10
+
11
+ def build_parser():
12
+ parser = argparse.ArgumentParser(
13
+ prog="magicicapsula",
14
+ description="seal files now, open them later",
15
+ )
16
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
17
+ sub.required = True
18
+
19
+ # every non-underscore module in commands/ with a register() becomes a command.
20
+ # drop in a new file and it shows up, nothing else to wire.
21
+ for _, name, _ in pkgutil.iter_modules(commands.__path__):
22
+ if name.startswith("_"):
23
+ continue
24
+ mod = importlib.import_module(f"magicicapsula.commands.{name}")
25
+ if hasattr(mod, "register"):
26
+ mod.register(sub)
27
+
28
+ return parser
29
+
30
+
31
+ def main():
32
+ args = build_parser().parse_args()
33
+ try:
34
+ args.func(args)
35
+ except CapsuleError as exc:
36
+ sys.exit(_style.red(f"error: {exc}"))
37
+ except FileNotFoundError as exc:
38
+ sys.exit(_style.red(f"error: no such file: {exc}"))
File without changes
@@ -0,0 +1,59 @@
1
+ """colors and the logo. presentation only, so it stays in the cli layer.
2
+
3
+ colors switch off automatically when output isn't a terminal, or when
4
+ NO_COLOR is set, so piped/redirected output stays clean.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import sys
10
+ from importlib import resources
11
+
12
+ _ANSI = re.compile(r"\x1b\[[0-9;]*m")
13
+
14
+
15
+ def enabled():
16
+ return (
17
+ sys.stdout.isatty()
18
+ and os.environ.get("NO_COLOR") is None
19
+ and os.environ.get("TERM") != "dumb"
20
+ )
21
+
22
+
23
+ def paint(text, code):
24
+ return f"\x1b[{code}m{text}\x1b[0m" if enabled() else text
25
+
26
+
27
+ def bold(t):
28
+ return paint(t, "1")
29
+
30
+
31
+ def dim(t):
32
+ return paint(t, "2")
33
+
34
+
35
+ def red(t):
36
+ return paint(t, "31")
37
+
38
+
39
+ def green(t):
40
+ return paint(t, "32")
41
+
42
+
43
+ def yellow(t):
44
+ return paint(t, "33")
45
+
46
+
47
+ def cyan(t):
48
+ return paint(t, "36")
49
+
50
+
51
+ def logo():
52
+ text = resources.files("magicicapsula").joinpath("assets/logo.txt").read_text(encoding="utf-8")
53
+ lines = text.splitlines()
54
+ while lines and not lines[0].strip():
55
+ lines.pop(0)
56
+ while lines and not lines[-1].strip():
57
+ lines.pop()
58
+ text = "\n".join(lines)
59
+ return text if enabled() else _ANSI.sub("", text)
@@ -0,0 +1,26 @@
1
+ """small helpers shared by the commands. underscore name so it isn't a command."""
2
+
3
+ import getpass
4
+ from datetime import timedelta
5
+
6
+
7
+ def read_capsule(path):
8
+ with open(path, "rb") as fh:
9
+ return fh.read()
10
+
11
+
12
+ def ask_password(confirm=False):
13
+ pw = getpass.getpass("password: ")
14
+ if not pw:
15
+ raise SystemExit("error: empty password")
16
+ if confirm and pw != getpass.getpass("confirm password: "):
17
+ raise SystemExit("error: passwords do not match")
18
+ return pw
19
+
20
+
21
+ def fmt_remaining(delta: timedelta) -> str:
22
+ secs = max(int(delta.total_seconds()), 0)
23
+ days, secs = divmod(secs, 86400)
24
+ hours, secs = divmod(secs, 3600)
25
+ mins = secs // 60
26
+ return f"{days}d {hours}h {mins}m"
@@ -0,0 +1,18 @@
1
+ from magicicapsula.core import draft
2
+
3
+
4
+ def register(sub):
5
+ p = sub.add_parser("add", help="stage files or folders to put in the capsule")
6
+ p.add_argument("paths", nargs="+", help="files or folders to stage")
7
+ p.set_defaults(func=run)
8
+
9
+
10
+ def run(args):
11
+ d = draft.load()
12
+ added = draft.add(d, args.paths)
13
+ if not added:
14
+ print("nothing new to stage")
15
+ return
16
+ for p in added:
17
+ print(f" staged {p}")
18
+ print(f"{len(d.staged)} item(s) staged in total")
@@ -0,0 +1,27 @@
1
+ from datetime import datetime, timezone
2
+
3
+ from magicicapsula.core import capsule
4
+ from magicicapsula.commands import _style
5
+ from magicicapsula.commands._util import fmt_remaining, read_capsule
6
+
7
+
8
+ def register(sub):
9
+ p = sub.add_parser("info", help="show a capsule's dates and status (no password needed)")
10
+ p.add_argument("file", help="capsule file")
11
+ p.set_defaults(func=run)
12
+
13
+
14
+ def run(args):
15
+ info = capsule.inspect(read_capsule(args.file))
16
+ now = datetime.now(timezone.utc)
17
+ print(f"created: {info.created_at.astimezone().isoformat()}")
18
+ print(f"unlocks: {info.unlock_at.astimezone().isoformat()}")
19
+ print(f"cipher: {info.cipher}")
20
+ if info.cipher == "none":
21
+ print(_style.dim(" no password, opens for anyone after the unlock date"))
22
+ if info.note:
23
+ print(f"note: {info.note}")
24
+ if info.is_open(now):
25
+ print(f"status: {_style.green('open')}, the unlock date has passed")
26
+ else:
27
+ print(f"status: {_style.yellow('locked')}, {fmt_remaining(info.unlock_at - now)} remaining")
@@ -0,0 +1,31 @@
1
+ from datetime import datetime
2
+
3
+ from magicicapsula.core import draft
4
+
5
+
6
+ def register(sub):
7
+ p = sub.add_parser("init", help="start a new capsule draft in the current directory")
8
+ p.add_argument("-u", "--unlock", metavar="DATE", help="unlock date, can also be set at seal")
9
+ p.add_argument("-n", "--note", default="", help="plaintext note shown by info")
10
+ p.add_argument("-o", "--out", default="capsule.mcap", help="output file name")
11
+ p.set_defaults(func=run)
12
+
13
+
14
+ def run(args):
15
+ try:
16
+ d = draft.init()
17
+ except FileExistsError:
18
+ raise SystemExit("error: a capsule draft already exists here (.capsule/)")
19
+
20
+ if args.unlock:
21
+ try:
22
+ datetime.fromisoformat(args.unlock)
23
+ except ValueError:
24
+ raise SystemExit(f"error: bad date {args.unlock!r} (use YYYY-MM-DD or YYYY-MM-DDTHH:MM)")
25
+ d.unlock_at = args.unlock
26
+ d.note = args.note
27
+ d.out = args.out
28
+ draft.save(d)
29
+
30
+ print(f"new capsule draft in {d.dir}")
31
+ print("next: magicicapsula add <files...>")
@@ -0,0 +1,29 @@
1
+ from datetime import datetime, timezone
2
+
3
+ from magicicapsula.core import capsule
4
+ from magicicapsula.commands import _style
5
+ from magicicapsula.commands._util import ask_password, fmt_remaining, read_capsule
6
+
7
+
8
+ def register(sub):
9
+ p = sub.add_parser("open", help="open a capsule and extract it once the unlock date has passed")
10
+ p.add_argument("file", help="capsule file")
11
+ p.add_argument("-d", "--dest", default=".", help="directory to extract into")
12
+ p.set_defaults(func=run)
13
+
14
+
15
+ def run(args):
16
+ blob = read_capsule(args.file)
17
+ info = capsule.inspect(blob)
18
+ now = datetime.now(timezone.utc)
19
+ if not info.is_open(now):
20
+ raise SystemExit(
21
+ f"error: locked until {info.unlock_at.astimezone().isoformat()} "
22
+ f"({fmt_remaining(info.unlock_at - now)} remaining)"
23
+ )
24
+
25
+ pw = None if info.cipher == "none" else ask_password()
26
+ names = capsule.open_capsule(blob, pw, args.dest)
27
+ print(_style.green(f"opened into {args.dest}/"))
28
+ for name in names:
29
+ print(f" {_style.dim(name)}")
@@ -0,0 +1,17 @@
1
+ from magicicapsula.core import draft
2
+
3
+
4
+ def register(sub):
5
+ p = sub.add_parser("rm", help="unstage files (does not delete them from disk)")
6
+ p.add_argument("paths", nargs="+", help="staged paths to drop from the capsule")
7
+ p.set_defaults(func=run)
8
+
9
+
10
+ def run(args):
11
+ d = draft.load()
12
+ removed = draft.remove(d, args.paths)
13
+ if not removed:
14
+ print("none of those were staged")
15
+ return
16
+ for p in removed:
17
+ print(f" unstaged {p}")
@@ -0,0 +1,66 @@
1
+ import os
2
+ import sys
3
+ from datetime import datetime, timezone
4
+
5
+ from magicicapsula.core import capsule, draft
6
+ from magicicapsula.commands import _style
7
+ from magicicapsula.commands._util import ask_password
8
+
9
+
10
+ def register(sub):
11
+ p = sub.add_parser("seal", help="seal everything staged into a capsule file")
12
+ p.add_argument("-u", "--unlock", metavar="DATE", help="unlock date, overrides the draft's")
13
+ p.add_argument("-o", "--out", metavar="FILE", help="output capsule file, overrides the draft's")
14
+ p.add_argument("-n", "--note", help="plaintext note, overrides the draft's")
15
+ p.add_argument("-f", "--force", action="store_true", help="overwrite the output if it exists")
16
+ p.add_argument("-P", "--no-password", action="store_true",
17
+ help="seal without a password (anyone can open it after the date)")
18
+ p.set_defaults(func=run)
19
+
20
+
21
+ def _parse_date(s):
22
+ try:
23
+ dt = datetime.fromisoformat(s)
24
+ except ValueError:
25
+ raise SystemExit(f"error: bad date {s!r} (use YYYY-MM-DD or YYYY-MM-DDTHH:MM)")
26
+ return dt.astimezone() if dt.tzinfo is None else dt # naive means local time
27
+
28
+
29
+ def run(args):
30
+ d = draft.load()
31
+
32
+ # flags override the draft and stick, so status keeps showing the right thing
33
+ if args.unlock is not None:
34
+ d.unlock_at = args.unlock
35
+ if args.note is not None:
36
+ d.note = args.note
37
+ if args.out is not None:
38
+ d.out = args.out
39
+ draft.save(d)
40
+
41
+ if not d.unlock_at:
42
+ raise SystemExit("error: no unlock date set (use --unlock, or set one at init)")
43
+ if not d.staged:
44
+ raise SystemExit("error: nothing staged (use: magicicapsula add <files>)")
45
+ gone = draft.missing(d)
46
+ if gone:
47
+ raise SystemExit("error: staged files no longer exist:\n " + "\n ".join(gone))
48
+
49
+ unlock_at = _parse_date(d.unlock_at)
50
+ if unlock_at <= datetime.now(timezone.utc):
51
+ print("warning: unlock date is not in the future", file=sys.stderr)
52
+
53
+ out = d.out if os.path.isabs(d.out) else os.path.join(d.root, d.out)
54
+ if os.path.exists(out) and not args.force:
55
+ raise SystemExit(f"error: {out} already exists (use --force to overwrite)")
56
+
57
+ pw = None if args.no_password else ask_password(confirm=True)
58
+ blob = capsule.seal(d.staged, pw, unlock_at, note=d.note)
59
+ with open(out, "wb") as fh:
60
+ fh.write(blob)
61
+
62
+ print(_style.logo())
63
+ print(_style.green(f"sealed {len(d.staged)} item(s) into {out}"))
64
+ print(f"unlocks: {unlock_at.astimezone().isoformat()}")
65
+ if pw is None:
66
+ print(_style.dim("no password set, so anyone can open it after that date"))
@@ -0,0 +1,37 @@
1
+ from datetime import datetime
2
+
3
+ from magicicapsula.core import draft
4
+ from magicicapsula.commands import _style
5
+
6
+
7
+ def register(sub):
8
+ p = sub.add_parser("status", help="show the draft: unlock date and staged files")
9
+ p.set_defaults(func=run)
10
+
11
+
12
+ def run(args):
13
+ d = draft.load()
14
+ print(_style.bold(f"draft at {d.dir}"))
15
+
16
+ if d.unlock_at:
17
+ dt = datetime.fromisoformat(d.unlock_at)
18
+ dt = dt.astimezone() if dt.tzinfo is None else dt
19
+ print(f"unlocks: {dt.isoformat()}")
20
+ else:
21
+ print("unlocks: not set (pass --unlock at init or seal)")
22
+ if d.note:
23
+ print(f"note: {d.note}")
24
+ print(f"output: {d.out}")
25
+ print()
26
+
27
+ if not d.staged:
28
+ print("nothing staged. use: magicicapsula add <files>")
29
+ return
30
+
31
+ gone = set(draft.missing(d))
32
+ print("staged:")
33
+ for p in d.staged:
34
+ print(f" {p}{_style.red(' (missing)') if p in gone else ''}")
35
+ print(f"\n{len(d.staged)} item(s) staged")
36
+ if gone:
37
+ print(_style.yellow("warning: some staged files no longer exist; fix before sealing"))
@@ -0,0 +1,18 @@
1
+ from magicicapsula.core import capsule
2
+ from magicicapsula.commands import _style
3
+ from magicicapsula.commands._util import ask_password, read_capsule
4
+
5
+
6
+ def register(sub):
7
+ p = sub.add_parser("verify", help="check a capsule's integrity with the password, without opening")
8
+ p.add_argument("file", help="capsule file")
9
+ p.set_defaults(func=run)
10
+
11
+
12
+ def run(args):
13
+ blob = read_capsule(args.file)
14
+ info = capsule.inspect(blob)
15
+ pw = None if info.cipher == "none" else ask_password()
16
+ capsule.verify(blob, pw)
17
+ tail = "" if pw is None else " and the password is correct"
18
+ print(_style.green(f"ok, capsule is intact{tail}"))
@@ -0,0 +1,14 @@
1
+ from magicicapsula import __version__
2
+ from magicicapsula.commands import _style
3
+
4
+
5
+ def register(sub):
6
+ p = sub.add_parser("version", help="show the version and logo")
7
+ p.set_defaults(func=run)
8
+
9
+
10
+ def run(args):
11
+ print(_style.logo())
12
+ print()
13
+ print(f" {_style.bold('magicicapsula')} {__version__}")
14
+ print(f" {_style.dim('seal files now, open them later')}")
@@ -0,0 +1,5 @@
1
+ """the pure library: encryption, capsule format, and the staging area.
2
+
3
+ no cli code and no printing here. the commands package is a thin layer
4
+ on top, so the same logic can be reused from other frontends later.
5
+ """
@@ -0,0 +1,165 @@
1
+ """the .mcap capsule format: pack files, seal, inspect, open.
2
+
3
+ one portable binary file you can store anywhere:
4
+
5
+ b"MCAP" 4 bytes magic
6
+ version 1 byte
7
+ header length 4 bytes uint32, big-endian
8
+ header N bytes json, utf-8 (dates, kdf params, salt, note)
9
+ ciphertext rest fernet token of a .tar.gz of the contents
10
+
11
+ the header is plaintext so inspect() can show dates without a password.
12
+ the contents, file names included, live only inside the ciphertext.
13
+ """
14
+
15
+ import base64
16
+ import io
17
+ import json
18
+ import os
19
+ import struct
20
+ import tarfile
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timedelta, timezone
23
+
24
+ from . import crypto
25
+ from .errors import CapsuleLocked, InvalidCapsule, WrongPasswordOrCorrupt
26
+
27
+ MAGIC = b"MCAP"
28
+ VERSION = 1
29
+
30
+
31
+ @dataclass
32
+ class CapsuleInfo:
33
+ """Non-secret metadata readable without the password."""
34
+
35
+ created_at: datetime
36
+ unlock_at: datetime
37
+ cipher: str
38
+ note: str
39
+
40
+ def is_open(self, now: datetime | None = None) -> bool:
41
+ now = now or datetime.now(timezone.utc)
42
+ return now >= self.unlock_at
43
+
44
+ def remaining(self, now: datetime | None = None) -> timedelta:
45
+ now = now or datetime.now(timezone.utc)
46
+ return max(self.unlock_at - now, timedelta(0))
47
+
48
+
49
+ def _iso(dt: datetime) -> str:
50
+ return dt.astimezone(timezone.utc).isoformat()
51
+
52
+
53
+ def _parse_iso(s: str) -> datetime:
54
+ dt = datetime.fromisoformat(s)
55
+ return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
56
+
57
+
58
+ def _pack(paths) -> bytes:
59
+ buf = io.BytesIO()
60
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
61
+ for path in paths:
62
+ path = os.path.normpath(path)
63
+ if not os.path.exists(path):
64
+ raise FileNotFoundError(path)
65
+ tar.add(path, arcname=os.path.basename(path))
66
+ return buf.getvalue()
67
+
68
+
69
+ def _unpack(blob: bytes, dest: str) -> list[str]:
70
+ os.makedirs(dest, exist_ok=True)
71
+ try:
72
+ with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
73
+ names = tar.getnames()
74
+ tar.extractall(dest, filter="data") # filter blocks path traversal
75
+ except tarfile.TarError as exc:
76
+ raise WrongPasswordOrCorrupt("the capsule is corrupted") from exc
77
+ return names
78
+
79
+
80
+ def list_names(blob: bytes) -> list[str]:
81
+ try:
82
+ with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
83
+ return tar.getnames()
84
+ except tarfile.TarError as exc:
85
+ raise WrongPasswordOrCorrupt("the capsule is corrupted") from exc
86
+
87
+
88
+ def seal(paths, password, unlock_at: datetime, note: str = "") -> bytes:
89
+ if unlock_at.tzinfo is None:
90
+ unlock_at = unlock_at.replace(tzinfo=timezone.utc)
91
+ payload = _pack(paths)
92
+ header = {
93
+ "v": VERSION,
94
+ "created_at": _iso(datetime.now(timezone.utc)),
95
+ "unlock_at": _iso(unlock_at),
96
+ "note": note,
97
+ }
98
+ if password:
99
+ salt = os.urandom(16)
100
+ params = crypto.KdfParams()
101
+ payload = crypto.encrypt(payload, password, salt, params)
102
+ header["cipher"] = "fernet"
103
+ header["kdf"] = {**params.to_dict(), "salt": base64.b64encode(salt).decode()}
104
+ else:
105
+ header["cipher"] = "none"
106
+ hb = json.dumps(header).encode("utf-8")
107
+ return MAGIC + bytes([VERSION]) + struct.pack(">I", len(hb)) + hb + payload
108
+
109
+
110
+ def _split(blob: bytes):
111
+ if blob[:4] != MAGIC:
112
+ raise InvalidCapsule("not a magicicapsula capsule (bad magic bytes)")
113
+ version = blob[4]
114
+ if version != VERSION:
115
+ raise InvalidCapsule(f"unsupported capsule version: {version}")
116
+ (hlen,) = struct.unpack(">I", blob[5:9])
117
+ try:
118
+ header = json.loads(blob[9:9 + hlen])
119
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
120
+ raise InvalidCapsule("corrupt header") from exc
121
+ return header, blob[9 + hlen:]
122
+
123
+
124
+ def inspect(blob: bytes) -> CapsuleInfo:
125
+ header, _ = _split(blob)
126
+ return CapsuleInfo(
127
+ created_at=_parse_iso(header["created_at"]),
128
+ unlock_at=_parse_iso(header["unlock_at"]),
129
+ cipher=header.get("cipher", "fernet"),
130
+ note=header.get("note", ""),
131
+ )
132
+
133
+
134
+ def _payload(blob: bytes, password) -> bytes:
135
+ header, token = _split(blob)
136
+ cipher = header.get("cipher", "fernet")
137
+ if cipher == "none":
138
+ return token
139
+ if cipher != "fernet":
140
+ raise InvalidCapsule(f"unknown cipher: {cipher}")
141
+ if not password:
142
+ raise WrongPasswordOrCorrupt("this capsule needs a password")
143
+ kdf = header["kdf"]
144
+ salt = base64.b64decode(kdf["salt"])
145
+ return crypto.decrypt(token, password, salt, crypto.KdfParams.from_dict(kdf))
146
+
147
+
148
+ def verify(blob: bytes, password) -> bool:
149
+ """Unpack the payload in memory without extracting. Raises on a bad password or corruption."""
150
+ list_names(_payload(blob, password)) # opening the tar catches a corrupt payload too
151
+ return True
152
+
153
+
154
+ def open_capsule(
155
+ blob: bytes,
156
+ password,
157
+ dest: str,
158
+ *,
159
+ now: datetime | None = None,
160
+ allow_locked: bool = False,
161
+ ) -> list[str]:
162
+ info = inspect(blob)
163
+ if not allow_locked and not info.is_open(now):
164
+ raise CapsuleLocked(info.unlock_at)
165
+ return _unpack(_payload(blob, password), dest)
@@ -0,0 +1,69 @@
1
+ """password-based authenticated encryption for capsule payloads.
2
+
3
+ kept separate from the capsule format and the cli, so swapping the
4
+ cipher later only touches this file. wraps the cryptography package
5
+ instead of hand-rolling anything.
6
+
7
+ password -> scrypt -> 32-byte key -> base64 -> fernet key.
8
+ fernet is aes-128-cbc + hmac-sha256, so it detects tampering.
9
+ """
10
+
11
+ import base64
12
+ import hashlib
13
+ from dataclasses import dataclass
14
+
15
+ from cryptography.fernet import Fernet, InvalidToken
16
+
17
+ from .errors import WrongPasswordOrCorrupt
18
+
19
+ # scrypt cost parameters. Memory cost ~= 128 * r * n bytes (~32 MB here).
20
+ SCRYPT_N = 2 ** 15
21
+ SCRYPT_R = 8
22
+ SCRYPT_P = 1
23
+ KEY_LEN = 32
24
+ _MAXMEM = 128 * SCRYPT_R * SCRYPT_N * 2 # headroom for hashlib.scrypt
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class KdfParams:
29
+ """Key-derivation parameters, stored (minus the salt) in the header."""
30
+
31
+ algo: str = "scrypt"
32
+ n: int = SCRYPT_N
33
+ r: int = SCRYPT_R
34
+ p: int = SCRYPT_P
35
+
36
+ def to_dict(self) -> dict:
37
+ return {"algo": self.algo, "n": self.n, "r": self.r, "p": self.p}
38
+
39
+ @classmethod
40
+ def from_dict(cls, d: dict) -> "KdfParams":
41
+ return cls(d.get("algo", "scrypt"), d["n"], d["r"], d["p"])
42
+
43
+
44
+ def derive_key(password: str, salt: bytes, params: KdfParams = KdfParams()) -> bytes:
45
+ if params.algo != "scrypt":
46
+ raise ValueError(f"unsupported KDF: {params.algo}")
47
+ raw = hashlib.scrypt(
48
+ password.encode("utf-8"),
49
+ salt=salt,
50
+ n=params.n,
51
+ r=params.r,
52
+ p=params.p,
53
+ dklen=KEY_LEN,
54
+ maxmem=_MAXMEM,
55
+ )
56
+ return base64.urlsafe_b64encode(raw)
57
+
58
+
59
+ def encrypt(data: bytes, password: str, salt: bytes, params: KdfParams = KdfParams()) -> bytes:
60
+ return Fernet(derive_key(password, salt, params)).encrypt(data)
61
+
62
+
63
+ def decrypt(token: bytes, password: str, salt: bytes, params: KdfParams = KdfParams()) -> bytes:
64
+ try:
65
+ return Fernet(derive_key(password, salt, params)).decrypt(token)
66
+ except InvalidToken as exc:
67
+ raise WrongPasswordOrCorrupt(
68
+ "wrong password, or the capsule has been altered/corrupted"
69
+ ) from exc
@@ -0,0 +1,108 @@
1
+ """the staging area ("draft") for building a capsule.
2
+
3
+ state lives in a .capsule/ directory, found by walking up from the cwd.
4
+ init creates it, add stages paths, status reports, and seal packs the
5
+ staged paths into a .mcap file.
6
+
7
+ staged entries are absolute paths to the current files on disk; their
8
+ contents are read at seal time, not copied when you add them.
9
+
10
+ no argparse, no printing here.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass, field
16
+
17
+ from .errors import NoDraft
18
+
19
+ DRAFT_DIR = ".capsule"
20
+ CONFIG = "config.json"
21
+ VERSION = 1
22
+
23
+
24
+ @dataclass
25
+ class Draft:
26
+ root: str # directory containing .capsule/
27
+ unlock_at: str | None = None # ISO date/datetime as typed by the user
28
+ note: str = ""
29
+ out: str = "capsule.mcap"
30
+ staged: list[str] = field(default_factory=list) # absolute paths
31
+
32
+ @property
33
+ def dir(self) -> str:
34
+ return os.path.join(self.root, DRAFT_DIR)
35
+
36
+ @property
37
+ def config_path(self) -> str:
38
+ return os.path.join(self.dir, CONFIG)
39
+
40
+
41
+ def find_root(start: str | None = None) -> str:
42
+ path = os.path.abspath(start or os.getcwd())
43
+ while True:
44
+ if os.path.isdir(os.path.join(path, DRAFT_DIR)):
45
+ return path
46
+ parent = os.path.dirname(path)
47
+ if parent == path:
48
+ raise NoDraft("no capsule here (run `magicicapsula init` first)")
49
+ path = parent
50
+
51
+
52
+ def init(root: str | None = None) -> Draft:
53
+ root = os.path.abspath(root or os.getcwd())
54
+ draft = Draft(root=root)
55
+ if os.path.exists(draft.dir):
56
+ raise FileExistsError(draft.dir)
57
+ os.makedirs(draft.dir)
58
+ save(draft)
59
+ return draft
60
+
61
+
62
+ def load(root: str | None = None) -> Draft:
63
+ root = root or find_root()
64
+ draft = Draft(root=root)
65
+ with open(draft.config_path, encoding="utf-8") as fh:
66
+ data = json.load(fh)
67
+ draft.unlock_at = data.get("unlock_at")
68
+ draft.note = data.get("note", "")
69
+ draft.out = data.get("out", "capsule.mcap")
70
+ draft.staged = data.get("staged", [])
71
+ return draft
72
+
73
+
74
+ def save(draft: Draft) -> None:
75
+ data = {
76
+ "v": VERSION,
77
+ "unlock_at": draft.unlock_at,
78
+ "note": draft.note,
79
+ "out": draft.out,
80
+ "staged": draft.staged,
81
+ }
82
+ with open(draft.config_path, "w", encoding="utf-8") as fh:
83
+ json.dump(data, fh, indent=2)
84
+
85
+
86
+ def add(draft: Draft, paths) -> list[str]:
87
+ added = []
88
+ for p in paths:
89
+ ap = os.path.abspath(p)
90
+ if not os.path.exists(ap):
91
+ raise FileNotFoundError(p)
92
+ if ap not in draft.staged:
93
+ draft.staged.append(ap)
94
+ added.append(ap)
95
+ save(draft)
96
+ return added
97
+
98
+
99
+ def remove(draft: Draft, paths) -> list[str]:
100
+ targets = {os.path.abspath(p) for p in paths}
101
+ removed = [s for s in draft.staged if s in targets]
102
+ draft.staged = [s for s in draft.staged if s not in targets]
103
+ save(draft)
104
+ return removed
105
+
106
+
107
+ def missing(draft: Draft) -> list[str]:
108
+ return [p for p in draft.staged if not os.path.exists(p)]
@@ -0,0 +1,29 @@
1
+ """errors raised by the core library.
2
+
3
+ the cli catches CapsuleError and prints a short message, so callers
4
+ never have to know the internals.
5
+ """
6
+
7
+
8
+ class CapsuleError(Exception):
9
+ """base class for anything that can go wrong with a capsule."""
10
+
11
+
12
+ class InvalidCapsule(CapsuleError):
13
+ """the bytes are not a valid capsule (bad magic, version, or header)."""
14
+
15
+
16
+ class WrongPasswordOrCorrupt(CapsuleError):
17
+ """decryption failed: wrong password, or the data was altered."""
18
+
19
+
20
+ class NoDraft(CapsuleError):
21
+ """no capsule draft found here (run init first)."""
22
+
23
+
24
+ class CapsuleLocked(CapsuleError):
25
+ """the unlock date has not arrived yet."""
26
+
27
+ def __init__(self, unlock_at):
28
+ self.unlock_at = unlock_at
29
+ super().__init__(f"capsule is locked until {unlock_at.isoformat()}")
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: magicicapsula
3
+ Version: 0.1.0
4
+ Summary: seal files now, open them later
5
+ Author-email: iDavi <odavi20527@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/iDavi/magicicapsula
8
+ Project-URL: Repository, https://github.com/iDavi/magicicapsula
9
+ Project-URL: Issues, https://github.com/iDavi/magicicapsula/issues
10
+ Keywords: time-capsule,encryption,cli,archive,vault
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security :: Cryptography
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: cryptography>=42
27
+ Dynamic: license-file
28
+
29
+ # magicicapsula
30
+
31
+ ## install
32
+
33
+ ```
34
+ pip install -e .
35
+ ```
36
+
37
+ needs python 3.10+. one dependency: `cryptography`.
38
+
39
+ ## how it works
40
+
41
+ the workflow is staged, so you don't have to add everything at once:
42
+
43
+ ```
44
+ magicicapsula init -u 2030-01-01 # start a draft here
45
+ magicicapsula add letter.txt photos/ # stage files/folders
46
+ magicicapsula add diary.txt # add more later
47
+ magicicapsula status # see what's staged
48
+ magicicapsula seal # pack it all into capsule.mcap
49
+ ```
50
+
51
+ the draft lives in a `.capsule/` directory (found by walking up from the
52
+ current dir). `seal` reads everything staged and writes the `.mcap` file.
53
+
54
+ later, when the date has passed:
55
+
56
+ ```
57
+ magicicapsula open capsule.mcap -d ./out
58
+ ```
59
+
60
+ `.mcap` is one portable binary file. store it anywhere, copy it around. it
61
+ holds any file type (images, pdfs, binaries) byte for byte, not just text.
62
+
63
+ ## passwords
64
+
65
+ - with a password (default): contents are encrypted (aes-128 via `cryptography`),
66
+ unreadable without the password. `open` and `verify` prompt for it.
67
+ - without a password (`seal --no-password`): no encryption. the unlock date is
68
+ the only gate, so anyone with the file can open it after that date. don't put
69
+ anything private in a no-password capsule.
70
+
71
+ note: the unlock date is enforced by the tool, not by cryptography. if you hold
72
+ the password you could decrypt early with other means. the date stops casual
73
+ early opening, not a determined holder.
74
+
75
+ ## commands
76
+
77
+ ### init
78
+ start a new capsule draft in the current directory.
79
+
80
+ ```
81
+ magicicapsula init [-u DATE] [-n NOTE] [-o OUT]
82
+
83
+ -u, --unlock DATE unlock date, can also be set at seal
84
+ -n, --note NOTE plaintext note shown by info
85
+ -o, --out OUT output file name (default: capsule.mcap)
86
+ ```
87
+
88
+ ### add
89
+ stage files or folders to put in the capsule.
90
+
91
+ ```
92
+ magicicapsula add <paths...>
93
+ ```
94
+
95
+ ### status
96
+ show the draft: unlock date and staged files.
97
+
98
+ ```
99
+ magicicapsula status
100
+ ```
101
+
102
+ ### rm
103
+ unstage files. does not delete them from disk.
104
+
105
+ ```
106
+ magicicapsula rm <paths...>
107
+ ```
108
+
109
+ ### seal
110
+ seal everything staged into a capsule file. flags override the draft's
111
+ settings and stick.
112
+
113
+ ```
114
+ magicicapsula seal [-u DATE] [-o FILE] [-n NOTE] [-f] [-P]
115
+
116
+ -u, --unlock DATE unlock date, overrides the draft's
117
+ -o, --out FILE output capsule file, overrides the draft's
118
+ -n, --note NOTE plaintext note, overrides the draft's
119
+ -f, --force overwrite the output if it exists
120
+ -P, --no-password seal without a password (anyone can open it after the date)
121
+ ```
122
+
123
+ ### info
124
+ show a capsule's dates and status. no password needed.
125
+
126
+ ```
127
+ magicicapsula info <file>
128
+ ```
129
+
130
+ ### open
131
+ open a capsule and extract it, once the unlock date has passed.
132
+
133
+ ```
134
+ magicicapsula open [-d DEST] <file>
135
+
136
+ -d, --dest DEST directory to extract into (default: current dir)
137
+ ```
138
+
139
+ ### verify
140
+ check a capsule's integrity (and the password, if any) without opening it.
141
+
142
+ ```
143
+ magicicapsula verify <file>
144
+ ```
145
+
146
+ ### version
147
+ show the version and logo.
148
+
149
+ ```
150
+ magicicapsula version
151
+ ```
152
+
153
+ ## date format
154
+
155
+ `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM`, read as local time. examples:
156
+ `2030-01-01`, `2030-01-01T08:00`.
157
+
158
+ ## colors
159
+
160
+ output is colored in a terminal and plain when piped or redirected. set
161
+ `NO_COLOR=1` to turn colors off.
162
+
163
+ ## dates and gotchas
164
+
165
+ - staged entries are paths, read at seal time, not copied when you add them.
166
+ if a staged file is moved or deleted before sealing, `status` marks it
167
+ `(missing)` and `seal` refuses until it's fixed.
168
+ - files are stored under their base name, so two staged files with the same
169
+ name would collide.
@@ -0,0 +1,27 @@
1
+ magicicapsula/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ magicicapsula/__main__.py,sha256=5TF6ulwui2K43AE-kesBxy_ANaenWnN1b2KfI9f6bmM,43
3
+ magicicapsula/cli.py,sha256=Z0fOuyWGp_QOira_so7CIDLppzfdnuQ_1TFzuQGupxs,1117
4
+ magicicapsula/assets/logo.txt,sha256=YsfYYo2fXXJq2wPaEMjInOfD4xZnadss0SWc9XTsiEY,5855
5
+ magicicapsula/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ magicicapsula/commands/_style.py,sha256=wrJS-pWQJSAE29rC-KAXGMmGM7sFT9o58rwdelV4AwM,1149
7
+ magicicapsula/commands/_util.py,sha256=vmAX4ugX29dT4JsWz3ReMe-rYujuT4M7JgHUcD8_mBs,716
8
+ magicicapsula/commands/add.py,sha256=11rjRa_W2hD5dMNRf1DqW4yESc7yTNCsXFCUbBEjtd8,494
9
+ magicicapsula/commands/info.py,sha256=uth9VGtcucXTh8rrd77OsYmqdzyB5d556RxK-3psaag,1057
10
+ magicicapsula/commands/init.py,sha256=k6k9pavKe7bOlsFECiMYrIm6HJFspx5Ipal-OIdbm6w,1034
11
+ magicicapsula/commands/open.py,sha256=tCxzGrOjCy6U3WYNWNa2X0Yu5ebKMjiIfsxWpgN-C4g,1046
12
+ magicicapsula/commands/rm.py,sha256=8bJO74Y2deNQ4c8R89kd1o0rO8wE3nRGewiFZ5qsi2I,469
13
+ magicicapsula/commands/seal.py,sha256=bR2N7DFn0SozR0RK_A8xKAg6fTFpFeZ3jAmKKgwwpow,2604
14
+ magicicapsula/commands/status.py,sha256=J0ytVAfuXkUOZZ0_nsDmI9gb886Cd50ajpLDzQE5iZM,1076
15
+ magicicapsula/commands/verify.py,sha256=zU78B3bolpcBKp_a_dvE64M4RLKS1ji-RafOjTCpEAc,644
16
+ magicicapsula/commands/version.py,sha256=uTB3w8GK-MdNY45VwXLFiAZtrfG64wKhNUpBwShPCLs,377
17
+ magicicapsula/core/__init__.py,sha256=3TK4lBrmpPmUHcwIBwyXTDVLsuhrylHOjVD-tgPlhZ0,215
18
+ magicicapsula/core/capsule.py,sha256=gCi_87dXJKHIVOLiuL-9x_qSDkM0LmD-BcP9_vkuAco,5219
19
+ magicicapsula/core/crypto.py,sha256=CwEzGQ47XtkibupaFwFfb6U_3Wxwti-Z2Yckzm152Tk,2099
20
+ magicicapsula/core/draft.py,sha256=wHGw0Ala4kKJ0ECF-TSjNUCh45yLPVhBPBaFnSvYZRc,3006
21
+ magicicapsula/core/errors.py,sha256=prq68jMBsNTE_-iaNQ6T14Qm4Du25sdrHw74gCcWCfY,796
22
+ magicicapsula-0.1.0.dist-info/licenses/LICENSE,sha256=1Z_qjZrSzXhIxveg1iheXdP2IYCT0QeHaem3w0ErlVU,1062
23
+ magicicapsula-0.1.0.dist-info/METADATA,sha256=pfqw_bgnFolEek6hX1FH3ilwNZFsM58PzvnFU1KRgC8,4664
24
+ magicicapsula-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
25
+ magicicapsula-0.1.0.dist-info/entry_points.txt,sha256=UywzEU4YIceH-NEUN5Xkx3khqj62ti5D7n0Pa5TRNMs,57
26
+ magicicapsula-0.1.0.dist-info/top_level.txt,sha256=oa2-Jq66XjBmMuIvQbFRLEO-jXc5xOL4cj4LPlPYejg,14
27
+ magicicapsula-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ magicicapsula = magicicapsula.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iDavi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ magicicapsula