obsideo-cli 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/PKG-INFO +26 -1
  2. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/README.md +25 -0
  3. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/cli.py +71 -1
  4. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/PKG-INFO +26 -1
  5. obsideo_cli-0.6.0/obsideo_core/crypto.py +94 -0
  6. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/pyproject.toml +1 -1
  7. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/tests/test_cli.py +84 -0
  8. obsideo_cli-0.5.0/obsideo_core/crypto.py +0 -50
  9. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/LICENSE +0 -0
  10. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/__init__.py +0 -0
  11. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/__main__.py +0 -0
  12. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/manifest.py +0 -0
  13. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/sync.py +0 -0
  14. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/SOURCES.txt +0 -0
  15. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  16. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/entry_points.txt +0 -0
  17. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/requires.txt +0 -0
  18. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/top_level.txt +0 -0
  19. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/__init__.py +0 -0
  20. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/config.py +0 -0
  21. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/identity.py +0 -0
  22. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/login.py +0 -0
  23. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/names.py +0 -0
  24. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/storage.py +0 -0
  25. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/setup.cfg +0 -0
  26. {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/tests/test_core.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Obsideo Cloud CLI - client-side encrypted storage for people and AI agents: content is encrypted on your machine before upload. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
@@ -70,9 +70,34 @@ obsideo:/trip/ get cat.jpg ./downloaded.jpg
70
70
  | `mkdir <name>` | Create a folder |
71
71
  | `info <remote>` | Show object metadata |
72
72
  | `account` | Show storage used vs. your free quota |
73
+ | `key status\|export\|import` | Back up or restore your encryption key (see below) |
73
74
  | `sync push\|pull\|status` | Sync your local folder with Obsideo |
74
75
  | `config [set k v]` | Show or change settings |
75
76
 
77
+ ## Using it on more than one machine
78
+
79
+ Your files are encrypted with a key generated on your machine and held only
80
+ there. **A second machine that logs in without that key gets a new one**, and
81
+ your existing files stay unreadable on it (they show as `?` in `ls`). Nobody can
82
+ reissue the key for you, us included.
83
+
84
+ So before you need it:
85
+
86
+ ```
87
+ obsideo key export # prints the line to save, or: key export ~/obsideo-key.txt
88
+ ```
89
+
90
+ Then on the other machine, before uploading anything:
91
+
92
+ ```
93
+ obsideo key import OBSIDEO_DATA_KEY=...
94
+ obsideo key status # same fingerprint on both = both can read the same files
95
+ ```
96
+
97
+ `key status` prints a fingerprint, not the key, so it is safe to compare over
98
+ any channel. Importing over an existing key needs `--force`, because it makes
99
+ whatever the old key protected unreadable.
100
+
76
101
  ## How it works
77
102
 
78
103
  `obsideo` is a thin front-end over the shared **`obsideo_core`** layer (storage
@@ -53,9 +53,34 @@ obsideo:/trip/ get cat.jpg ./downloaded.jpg
53
53
  | `mkdir <name>` | Create a folder |
54
54
  | `info <remote>` | Show object metadata |
55
55
  | `account` | Show storage used vs. your free quota |
56
+ | `key status\|export\|import` | Back up or restore your encryption key (see below) |
56
57
  | `sync push\|pull\|status` | Sync your local folder with Obsideo |
57
58
  | `config [set k v]` | Show or change settings |
58
59
 
60
+ ## Using it on more than one machine
61
+
62
+ Your files are encrypted with a key generated on your machine and held only
63
+ there. **A second machine that logs in without that key gets a new one**, and
64
+ your existing files stay unreadable on it (they show as `?` in `ls`). Nobody can
65
+ reissue the key for you, us included.
66
+
67
+ So before you need it:
68
+
69
+ ```
70
+ obsideo key export # prints the line to save, or: key export ~/obsideo-key.txt
71
+ ```
72
+
73
+ Then on the other machine, before uploading anything:
74
+
75
+ ```
76
+ obsideo key import OBSIDEO_DATA_KEY=...
77
+ obsideo key status # same fingerprint on both = both can read the same files
78
+ ```
79
+
80
+ `key status` prints a fingerprint, not the key, so it is safe to compare over
81
+ any channel. Importing over an existing key needs `--force`, because it makes
82
+ whatever the old key protected unreadable.
83
+
59
84
  ## How it works
60
85
 
61
86
  `obsideo` is a thin front-end over the shared **`obsideo_core`** layer (storage
@@ -462,6 +462,21 @@ def run_login(url: str | None = None, *, email: str | None = None,
462
462
  return False
463
463
  say(" done.")
464
464
  storage.reset_client()
465
+ # A machine with NO key signing in to an account that already holds objects
466
+ # is the one moment this goes irreversibly wrong: data_key() below mints a
467
+ # fresh key, and everything already uploaded becomes unreadable here with no
468
+ # way back. Say so while the user can still import the real one.
469
+ if not crypto.has_data_key():
470
+ existing = _fetch_account_info() or {}
471
+ held = existing.get("object_count") or 0
472
+ if held > 0:
473
+ say("")
474
+ say(f" !! This account already holds {held} object(s), encrypted with a key this")
475
+ say(" machine does not have. A new key is about to be created, and those")
476
+ say(" files will NOT be readable here (they show as '?' in ls).")
477
+ say(" If you have the key from your other machine, stop now and run:")
478
+ say(" obsideo key import <your OBSIDEO_DATA_KEY line>")
479
+ say("")
465
480
  # Make sure the data key exists + nudge the user to back it up.
466
481
  crypto.data_key()
467
482
  if json_out:
@@ -492,6 +507,8 @@ def run_login(url: str | None = None, *, email: str | None = None,
492
507
  print("Note: storage activation is finishing rollout; if an upload fails, retry shortly.")
493
508
  print("Your files are encrypted with a local key. Back it up:")
494
509
  print(f" {crypto.DATA_KEY_FILE}")
510
+ print(" Save a copy now with 'obsideo key export' - without it, nobody can")
511
+ print(" recover your files, including us.")
495
512
  # Create the sync folder now so it's ready (never make the user mkdir it).
496
513
  try:
497
514
  from obsideo import sync as _sync
@@ -548,7 +565,7 @@ class ObsideoShell(cmd.Cmd):
548
565
  # ── help ────────────────────────────────────────────────────────────────
549
566
  # Order the table reads in, roughly "get in, move around, move bytes, admin".
550
567
  _HELP_ORDER = ["login", "ls", "cd", "pwd", "put", "get", "rm", "mkdir",
551
- "info", "account", "sync", "config", "refer", "messages",
568
+ "info", "account", "key", "sync", "config", "refer", "messages",
552
569
  "about", "faq", "help", "exit"]
553
570
 
554
571
  def do_help(self, arg):
@@ -842,6 +859,59 @@ class ObsideoShell(cmd.Cmd):
842
859
  print(" ---------------------------------------------")
843
860
  print()
844
861
 
862
+ def do_key(self, arg):
863
+ """Back up, restore, or identify your encryption key. Usage: key status | key export [file] | key import <value|file> [--force]"""
864
+ toks = _tokens(arg)
865
+ sub = toks[0].lower() if toks else "status"
866
+ rest = toks[1:]
867
+
868
+ if sub == "status":
869
+ if not crypto.has_data_key():
870
+ print(" No encryption key on this machine yet.")
871
+ print(" One is created the first time you upload. If you already have an")
872
+ print(" account with files, run 'key import' FIRST or they stay unreadable here.")
873
+ return
874
+ print(f" Key file: {crypto.DATA_KEY_FILE}")
875
+ print(f" Fingerprint: {crypto.data_key_fingerprint()}")
876
+ print(" Two machines showing the same fingerprint can read the same files.")
877
+ print(" Back it up with 'key export'. Lose it and the data is unreadable, by design.")
878
+
879
+ elif sub == "export":
880
+ target = next((t for t in rest if not t.startswith("--")), None)
881
+ hint = crypto.data_key_backup_hint()
882
+ if target:
883
+ path = Path(_unquote(target))
884
+ config.write_secret_file(path, hint + chr(10))
885
+ print(f" Key written to {path} (fingerprint {crypto.data_key_fingerprint()})")
886
+ print(" That file is as sensitive as everything it protects. Store it offline.")
887
+ else:
888
+ print(" Save this line somewhere safe. It is the only way to read your files")
889
+ print(" from another machine, and nobody can reissue it for you:")
890
+ print()
891
+ print(f" {hint}")
892
+ print()
893
+ print(" On the other machine: obsideo key import <that line>")
894
+
895
+ elif sub == "import":
896
+ if not rest:
897
+ print("Usage: key import <value|file> [--force]")
898
+ return
899
+ force = "--force" in rest
900
+ value = next((t for t in rest if not t.startswith("--")), "")
901
+ src = Path(_unquote(value))
902
+ try:
903
+ if src.exists():
904
+ value = src.read_text(encoding="utf-8")
905
+ crypto.import_data_key(value, force=force)
906
+ except (ValueError, FileExistsError, OSError) as e:
907
+ print(f" {e}")
908
+ return
909
+ print(f" Key installed. Fingerprint: {crypto.data_key_fingerprint()}")
910
+ print(" Run 'ls' - files that showed as '?' should now show their real names.")
911
+
912
+ else:
913
+ print("Usage: key status | key export [file] | key import <value|file> [--force]")
914
+
845
915
  # ── about / faq / messages ────────────────────────────────────────────────
846
916
  def do_about(self, arg):
847
917
  """What Obsideo is."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Obsideo Cloud CLI - client-side encrypted storage for people and AI agents: content is encrypted on your machine before upload. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
@@ -70,9 +70,34 @@ obsideo:/trip/ get cat.jpg ./downloaded.jpg
70
70
  | `mkdir <name>` | Create a folder |
71
71
  | `info <remote>` | Show object metadata |
72
72
  | `account` | Show storage used vs. your free quota |
73
+ | `key status\|export\|import` | Back up or restore your encryption key (see below) |
73
74
  | `sync push\|pull\|status` | Sync your local folder with Obsideo |
74
75
  | `config [set k v]` | Show or change settings |
75
76
 
77
+ ## Using it on more than one machine
78
+
79
+ Your files are encrypted with a key generated on your machine and held only
80
+ there. **A second machine that logs in without that key gets a new one**, and
81
+ your existing files stay unreadable on it (they show as `?` in `ls`). Nobody can
82
+ reissue the key for you, us included.
83
+
84
+ So before you need it:
85
+
86
+ ```
87
+ obsideo key export # prints the line to save, or: key export ~/obsideo-key.txt
88
+ ```
89
+
90
+ Then on the other machine, before uploading anything:
91
+
92
+ ```
93
+ obsideo key import OBSIDEO_DATA_KEY=...
94
+ obsideo key status # same fingerprint on both = both can read the same files
95
+ ```
96
+
97
+ `key status` prints a fingerprint, not the key, so it is safe to compare over
98
+ any channel. Importing over an existing key needs `--force`, because it makes
99
+ whatever the old key protected unreadable.
100
+
76
101
  ## How it works
77
102
 
78
103
  `obsideo` is a thin front-end over the shared **`obsideo_core`** layer (storage
@@ -0,0 +1,94 @@
1
+ """Account-level AES-256-GCM encryption for the general CLI.
2
+
3
+ One data key per account, held locally at ~/.obsideo/data.key. Every file is
4
+ encrypted with that key and a fresh random nonce (prepended). Any file the
5
+ account uploaded can be decrypted with this one key, which is what makes
6
+ browse/download/sync work across machines — copy this key to a new machine and
7
+ everything is readable.
8
+
9
+ This differs from the mlvault extension's per-run keys (immutable ML bundles); a
10
+ general file store wants one stable key. Lose the key, lose the data — by design.
11
+ Back it up alongside your credentials.
12
+ """
13
+
14
+ import os
15
+
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+
18
+ from obsideo_core import config
19
+
20
+ DATA_KEY_FILE = config.CONFIG_DIR / "data.key"
21
+
22
+
23
+ def data_key() -> bytes:
24
+ """Load or generate the 32-byte account data key."""
25
+ env = os.environ.get("OBSIDEO_DATA_KEY", "").strip()
26
+ if env:
27
+ return bytes.fromhex(env)
28
+ if DATA_KEY_FILE.exists():
29
+ return bytes.fromhex(DATA_KEY_FILE.read_text().strip())
30
+ key = os.urandom(32)
31
+ config.write_secret_file(DATA_KEY_FILE, key.hex())
32
+ return key
33
+
34
+
35
+ def encrypt(data: bytes) -> bytes:
36
+ """AES-256-GCM. Returns nonce(12) + ciphertext+tag."""
37
+ key = data_key()
38
+ nonce = os.urandom(12)
39
+ return nonce + AESGCM(key).encrypt(nonce, data, None)
40
+
41
+
42
+ def decrypt(blob: bytes) -> bytes:
43
+ """Inverse of encrypt. Raises on auth failure / wrong key."""
44
+ key = data_key()
45
+ nonce, ct = blob[:12], blob[12:]
46
+ return AESGCM(key).decrypt(nonce, ct, None)
47
+
48
+
49
+ def has_data_key() -> bool:
50
+ """True if a key is already materialised for this config dir. Callers use
51
+ this to detect the dangerous case: a machine with no key signing in to an
52
+ account that already holds objects, where generating a fresh key silently
53
+ orphans everything already uploaded."""
54
+ return bool(os.environ.get("OBSIDEO_DATA_KEY", "").strip()) or DATA_KEY_FILE.exists()
55
+
56
+
57
+ def data_key_fingerprint() -> str:
58
+ """A short, non-secret identifier for the current key: the first 16 hex
59
+ chars of its SHA-256. Safe to print, log, or read down a phone line. Two
60
+ machines showing the same fingerprint hold the same key; different
61
+ fingerprints mean each can only read what it wrote."""
62
+ import hashlib
63
+ return hashlib.sha256(data_key()).hexdigest()[:16]
64
+
65
+
66
+ def import_data_key(value: str, *, force: bool = False) -> None:
67
+ """Install a data key from an exported value.
68
+
69
+ Accepts the bare 64-char hex key or the full `OBSIDEO_DATA_KEY=<hex>` line
70
+ that `key export` prints, so pasting either works. Refuses to overwrite an
71
+ existing key unless force is set: overwriting is exactly as destructive as
72
+ losing the file, and it is not obvious from the command name.
73
+ """
74
+ raw = value.strip()
75
+ if "=" in raw:
76
+ raw = raw.split("=", 1)[1].strip()
77
+ raw = raw.strip("'\"")
78
+ try:
79
+ key = bytes.fromhex(raw)
80
+ except ValueError as e:
81
+ raise ValueError("Not a valid key: expected 64 hexadecimal characters.") from e
82
+ if len(key) != 32:
83
+ raise ValueError(f"Not a valid key: expected 32 bytes (64 hex chars), got {len(key)}.")
84
+ if DATA_KEY_FILE.exists() and not force:
85
+ raise FileExistsError(
86
+ f"A key already exists at {DATA_KEY_FILE}. Importing over it makes anything "
87
+ "encrypted with the current key unreadable. Back it up with 'obsideo key export' "
88
+ "first, then re-run with --force."
89
+ )
90
+ config.write_secret_file(DATA_KEY_FILE, key.hex())
91
+
92
+
93
+ def data_key_backup_hint() -> str:
94
+ return f"OBSIDEO_DATA_KEY={data_key().hex()}"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "obsideo-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Obsideo Cloud CLI - client-side encrypted storage for people and AI agents: content is encrypted on your machine before upload. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -439,3 +439,87 @@ def test_account_shows_referral_line(monkeypatch, tmp_path, capsys):
439
439
  cli.ObsideoShell().do_account("")
440
440
  out = capsys.readouterr().out
441
441
  assert "Referrals: code ABCD234" in out and "4 GB earned" in out
442
+
443
+
444
+ # ── Key portability ──────────────────────────────────────────────────────────
445
+ # data_key() mints os.urandom(32) whenever the file is absent, so signing in on
446
+ # a second machine silently orphans everything already uploaded. These cover the
447
+ # escape hatch and, more importantly, the warning that fires before it happens.
448
+
449
+ @pytest.fixture
450
+ def key_dir(tmp_path, monkeypatch):
451
+ """Isolate the config dir so nothing here can touch a real ~/.obsideo."""
452
+ monkeypatch.delenv("OBSIDEO_DATA_KEY", raising=False)
453
+ monkeypatch.setattr(crypto.config, "CONFIG_DIR", tmp_path)
454
+ monkeypatch.setattr(crypto, "DATA_KEY_FILE", tmp_path / "data.key")
455
+ return tmp_path
456
+
457
+
458
+ def test_has_data_key_false_on_a_fresh_machine(key_dir):
459
+ assert crypto.has_data_key() is False
460
+
461
+
462
+ def test_import_accepts_bare_hex_and_the_exported_line(key_dir):
463
+ raw = os.urandom(32).hex()
464
+ crypto.import_data_key(raw)
465
+ first = crypto.data_key_fingerprint()
466
+ crypto.DATA_KEY_FILE.unlink()
467
+ crypto.import_data_key(f"OBSIDEO_DATA_KEY={raw}")
468
+ assert crypto.data_key_fingerprint() == first
469
+ assert crypto.has_data_key() is True
470
+
471
+
472
+ def test_import_rejects_malformed_values(key_dir):
473
+ for bad in ("not-hex", "abcd", os.urandom(16).hex()):
474
+ with pytest.raises(ValueError):
475
+ crypto.import_data_key(bad)
476
+
477
+
478
+ def test_import_refuses_to_clobber_without_force(key_dir):
479
+ crypto.import_data_key(os.urandom(32).hex())
480
+ before = crypto.data_key_fingerprint()
481
+ with pytest.raises(FileExistsError):
482
+ crypto.import_data_key(os.urandom(32).hex())
483
+ assert crypto.data_key_fingerprint() == before # untouched
484
+ crypto.import_data_key(os.urandom(32).hex(), force=True)
485
+ assert crypto.data_key_fingerprint() != before
486
+
487
+
488
+ def test_fingerprint_never_reveals_the_key(key_dir):
489
+ raw = os.urandom(32).hex()
490
+ crypto.import_data_key(raw)
491
+ fp = crypto.data_key_fingerprint()
492
+ assert len(fp) == 16 and fp not in raw and raw not in fp
493
+
494
+
495
+ def test_login_warns_before_minting_a_key_over_an_account_that_has_objects(monkeypatch, capsys, tmp_path):
496
+ """The whole point: a machine with no key signing in to an account that
497
+ already holds files must be told BEFORE the fresh key is created."""
498
+ monkeypatch.delenv("OBSIDEO_DATA_KEY", raising=False)
499
+ monkeypatch.setattr(crypto, "DATA_KEY_FILE", tmp_path / "data.key")
500
+ monkeypatch.setattr(cli.crypto, "has_data_key", lambda: False)
501
+ monkeypatch.setattr(cli.crypto, "data_key", lambda: b"\x00" * 32)
502
+ monkeypatch.setattr(cli, "_fetch_account_info", lambda: {"object_count": 7})
503
+ monkeypatch.setattr(cli, "_fetch_referral", lambda: None)
504
+ monkeypatch.setattr(cli.storage, "reset_client", lambda: None)
505
+ monkeypatch.setattr(cli.login, "verify", lambda *a, **k: {"quota_gb": 12})
506
+
507
+ cli.run_login(email="x@example.com", code="123456")
508
+ out = capsys.readouterr().out
509
+ assert "already holds 7 object(s)" in out
510
+ assert "key import" in out
511
+
512
+
513
+ def test_login_stays_quiet_for_a_genuinely_new_account(monkeypatch, capsys, tmp_path):
514
+ monkeypatch.delenv("OBSIDEO_DATA_KEY", raising=False)
515
+ monkeypatch.setattr(crypto, "DATA_KEY_FILE", tmp_path / "data.key")
516
+ monkeypatch.setattr(cli.crypto, "has_data_key", lambda: False)
517
+ monkeypatch.setattr(cli.crypto, "data_key", lambda: b"\x00" * 32)
518
+ monkeypatch.setattr(cli, "_fetch_account_info", lambda: {"object_count": 0})
519
+ monkeypatch.setattr(cli, "_fetch_referral", lambda: None)
520
+ monkeypatch.setattr(cli.storage, "reset_client", lambda: None)
521
+ monkeypatch.setattr(cli.login, "verify", lambda *a, **k: {"quota_gb": 12})
522
+
523
+ cli.run_login(email="x@example.com", code="123456")
524
+ out = capsys.readouterr().out
525
+ assert "already holds" not in out
@@ -1,50 +0,0 @@
1
- """Account-level AES-256-GCM encryption for the general CLI.
2
-
3
- One data key per account, held locally at ~/.obsideo/data.key. Every file is
4
- encrypted with that key and a fresh random nonce (prepended). Any file the
5
- account uploaded can be decrypted with this one key, which is what makes
6
- browse/download/sync work across machines — copy this key to a new machine and
7
- everything is readable.
8
-
9
- This differs from the mlvault extension's per-run keys (immutable ML bundles); a
10
- general file store wants one stable key. Lose the key, lose the data — by design.
11
- Back it up alongside your credentials.
12
- """
13
-
14
- import os
15
-
16
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
-
18
- from obsideo_core import config
19
-
20
- DATA_KEY_FILE = config.CONFIG_DIR / "data.key"
21
-
22
-
23
- def data_key() -> bytes:
24
- """Load or generate the 32-byte account data key."""
25
- env = os.environ.get("OBSIDEO_DATA_KEY", "").strip()
26
- if env:
27
- return bytes.fromhex(env)
28
- if DATA_KEY_FILE.exists():
29
- return bytes.fromhex(DATA_KEY_FILE.read_text().strip())
30
- key = os.urandom(32)
31
- config.write_secret_file(DATA_KEY_FILE, key.hex())
32
- return key
33
-
34
-
35
- def encrypt(data: bytes) -> bytes:
36
- """AES-256-GCM. Returns nonce(12) + ciphertext+tag."""
37
- key = data_key()
38
- nonce = os.urandom(12)
39
- return nonce + AESGCM(key).encrypt(nonce, data, None)
40
-
41
-
42
- def decrypt(blob: bytes) -> bytes:
43
- """Inverse of encrypt. Raises on auth failure / wrong key."""
44
- key = data_key()
45
- nonce, ct = blob[:12], blob[12:]
46
- return AESGCM(key).decrypt(nonce, ct, None)
47
-
48
-
49
- def data_key_backup_hint() -> str:
50
- return f"OBSIDEO_DATA_KEY={data_key().hex()}"
File without changes
File without changes
File without changes