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.
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/PKG-INFO +26 -1
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/README.md +25 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/cli.py +71 -1
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/PKG-INFO +26 -1
- obsideo_cli-0.6.0/obsideo_core/crypto.py +94 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/pyproject.toml +1 -1
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/tests/test_cli.py +84 -0
- obsideo_cli-0.5.0/obsideo_core/crypto.py +0 -50
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/LICENSE +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/__init__.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/__main__.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/manifest.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo/sync.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/SOURCES.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/dependency_links.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/entry_points.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/requires.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_cli.egg-info/top_level.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/__init__.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/config.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/identity.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/login.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/names.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/obsideo_core/storage.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.0}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|