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.
- magicicapsula/__init__.py +1 -0
- magicicapsula/__main__.py +3 -0
- magicicapsula/assets/logo.txt +27 -0
- magicicapsula/cli.py +38 -0
- magicicapsula/commands/__init__.py +0 -0
- magicicapsula/commands/_style.py +59 -0
- magicicapsula/commands/_util.py +26 -0
- magicicapsula/commands/add.py +18 -0
- magicicapsula/commands/info.py +27 -0
- magicicapsula/commands/init.py +31 -0
- magicicapsula/commands/open.py +29 -0
- magicicapsula/commands/rm.py +17 -0
- magicicapsula/commands/seal.py +66 -0
- magicicapsula/commands/status.py +37 -0
- magicicapsula/commands/verify.py +18 -0
- magicicapsula/commands/version.py +14 -0
- magicicapsula/core/__init__.py +5 -0
- magicicapsula/core/capsule.py +165 -0
- magicicapsula/core/crypto.py +69 -0
- magicicapsula/core/draft.py +108 -0
- magicicapsula/core/errors.py +29 -0
- magicicapsula-0.1.0.dist-info/METADATA +169 -0
- magicicapsula-0.1.0.dist-info/RECORD +27 -0
- magicicapsula-0.1.0.dist-info/WHEEL +5 -0
- magicicapsula-0.1.0.dist-info/entry_points.txt +2 -0
- magicicapsula-0.1.0.dist-info/licenses/LICENSE +21 -0
- magicicapsula-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m
|
|
3
|
+
[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;244;244;244m░[0m[38;2;244;244;244m░[0m
|
|
4
|
+
[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m[38;2;244;244;244m░[0m
|
|
5
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;244;244;244m░[0m[38;2;240;0;12m▓[0m[38;2;240;0;12m▓[0m[38;2;240;0;12m▓[0m [38;2;244;244;244m░[0m
|
|
6
|
+
[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;244;244;244m░[0m[38;2;240;0;12m▓[0m[38;2;240;0;12m▓[0m[38;2;244;244;244m░[0m[38;2;240;0;12m▓[0m[38;2;240;0;12m▓[0m [38;2;244;244;244m░[0m
|
|
7
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m [38;2;240;0;12m▓[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;240;0;12m▓[0m [38;2;244;244;244m░[0m
|
|
8
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m [38;2;240;0;12m▓[0m[38;2;240;0;12m▓[0m[38;2;244;244;244m░[0m[38;2;240;0;12m▓[0m[38;2;240;0;12m▓[0m [38;2;244;244;244m░[0m
|
|
9
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m [38;2;240;0;12m▓[0m[38;2;240;0;12m▓[0m[38;2;240;0;12m▓[0m [38;2;244;244;244m░[0m
|
|
10
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m [38;2;244;244;244m░[0m
|
|
11
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m [38;2;244;244;244m░[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m
|
|
12
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;153;133;0m▒[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m [38;2;244;244;244m░[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m
|
|
13
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;153;133;0m▒[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m [38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m
|
|
14
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m[38;2;0;0;0m█[0m[38;2;153;133;0m▒[0m [38;2;244;244;244m░[0m
|
|
15
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m
|
|
16
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m [38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m
|
|
17
|
+
[38;2;107;75;0m▓[0m [38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m [38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m
|
|
18
|
+
[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m [38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m
|
|
19
|
+
[38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m[38;2;107;75;0m▓[0m[38;2;107;75;0m▓[0m[38;2;244;244;244m░[0m
|
|
20
|
+
[38;2;244;244;244m░[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m[38;2;244;244;244m░[0m [38;2;244;244;244m░[0m[38;2;244;244;244m░[0m
|
|
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,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,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
|