obsideo-cli 0.5.0__tar.gz → 0.6.1__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.1}/PKG-INFO +26 -1
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/README.md +25 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/__init__.py +1 -1
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/__main__.py +4 -4
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/cli.py +71 -1
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/manifest.py +59 -59
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/PKG-INFO +26 -1
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/SOURCES.txt +2 -1
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/__init__.py +4 -4
- obsideo_cli-0.6.1/obsideo_core/crypto.py +94 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/identity.py +36 -36
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/names.py +65 -65
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/storage.py +46 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/pyproject.toml +33 -33
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/tests/test_cli.py +84 -0
- obsideo_cli-0.6.1/tests/test_send_buffer.py +46 -0
- obsideo_cli-0.5.0/obsideo_core/crypto.py +0 -50
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/LICENSE +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/sync.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/dependency_links.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/entry_points.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/requires.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/top_level.txt +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/config.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/login.py +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/setup.cfg +0 -0
- {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/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.1
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"""The general Obsideo CLI front-end (REPL + browse + sync) on obsideo_core."""
|
|
1
|
+
"""The general Obsideo CLI front-end (REPL + browse + sync) on obsideo_core."""
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from obsideo.cli import main
|
|
2
|
-
|
|
3
|
-
if __name__ == "__main__":
|
|
4
|
-
main()
|
|
1
|
+
from obsideo.cli import main
|
|
2
|
+
|
|
3
|
+
if __name__ == "__main__":
|
|
4
|
+
main()
|
|
@@ -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,59 +1,59 @@
|
|
|
1
|
-
"""Local manifest for tracking synced files (lifted from Cloud_Terminal,
|
|
2
|
-
re-homed under ~/.obsideo)."""
|
|
3
|
-
|
|
4
|
-
import hashlib
|
|
5
|
-
import json
|
|
6
|
-
from datetime import datetime, timezone
|
|
7
|
-
|
|
8
|
-
from obsideo_core import config
|
|
9
|
-
|
|
10
|
-
_MANIFEST_FILE = config.CONFIG_DIR / "manifest.json"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _load() -> dict:
|
|
14
|
-
if not _MANIFEST_FILE.exists():
|
|
15
|
-
return {"files": {}}
|
|
16
|
-
try:
|
|
17
|
-
data = json.loads(_MANIFEST_FILE.read_text())
|
|
18
|
-
if not isinstance(data, dict):
|
|
19
|
-
return {"files": {}}
|
|
20
|
-
data.setdefault("files", {})
|
|
21
|
-
return data
|
|
22
|
-
except Exception:
|
|
23
|
-
return {"files": {}}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _save(m: dict) -> None:
|
|
27
|
-
_MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
-
_MANIFEST_FILE.write_text(json.dumps(m, indent=2, sort_keys=True))
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def upsert(name: str, remote_key: str, local_hash: str | None = None,
|
|
32
|
-
size: int | None = None, encrypted: bool = True) -> None:
|
|
33
|
-
m = _load()
|
|
34
|
-
m["files"][name] = {
|
|
35
|
-
"remote_key": remote_key,
|
|
36
|
-
"local_hash": local_hash,
|
|
37
|
-
"size": size,
|
|
38
|
-
"encrypted": encrypted,
|
|
39
|
-
"last_synced": datetime.now(timezone.utc).isoformat(),
|
|
40
|
-
}
|
|
41
|
-
_save(m)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def remove(name: str) -> None:
|
|
45
|
-
m = _load()
|
|
46
|
-
m["files"].pop(name, None)
|
|
47
|
-
_save(m)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def get_all() -> dict:
|
|
51
|
-
return _load()["files"]
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def file_sha256(filepath) -> str:
|
|
55
|
-
h = hashlib.sha256()
|
|
56
|
-
with open(filepath, "rb") as f:
|
|
57
|
-
for chunk in iter(lambda: f.read(8192), b""):
|
|
58
|
-
h.update(chunk)
|
|
59
|
-
return f"sha256:{h.hexdigest()}"
|
|
1
|
+
"""Local manifest for tracking synced files (lifted from Cloud_Terminal,
|
|
2
|
+
re-homed under ~/.obsideo)."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from obsideo_core import config
|
|
9
|
+
|
|
10
|
+
_MANIFEST_FILE = config.CONFIG_DIR / "manifest.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load() -> dict:
|
|
14
|
+
if not _MANIFEST_FILE.exists():
|
|
15
|
+
return {"files": {}}
|
|
16
|
+
try:
|
|
17
|
+
data = json.loads(_MANIFEST_FILE.read_text())
|
|
18
|
+
if not isinstance(data, dict):
|
|
19
|
+
return {"files": {}}
|
|
20
|
+
data.setdefault("files", {})
|
|
21
|
+
return data
|
|
22
|
+
except Exception:
|
|
23
|
+
return {"files": {}}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _save(m: dict) -> None:
|
|
27
|
+
_MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
_MANIFEST_FILE.write_text(json.dumps(m, indent=2, sort_keys=True))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def upsert(name: str, remote_key: str, local_hash: str | None = None,
|
|
32
|
+
size: int | None = None, encrypted: bool = True) -> None:
|
|
33
|
+
m = _load()
|
|
34
|
+
m["files"][name] = {
|
|
35
|
+
"remote_key": remote_key,
|
|
36
|
+
"local_hash": local_hash,
|
|
37
|
+
"size": size,
|
|
38
|
+
"encrypted": encrypted,
|
|
39
|
+
"last_synced": datetime.now(timezone.utc).isoformat(),
|
|
40
|
+
}
|
|
41
|
+
_save(m)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def remove(name: str) -> None:
|
|
45
|
+
m = _load()
|
|
46
|
+
m["files"].pop(name, None)
|
|
47
|
+
_save(m)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_all() -> dict:
|
|
51
|
+
return _load()["files"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def file_sha256(filepath) -> str:
|
|
55
|
+
h = hashlib.sha256()
|
|
56
|
+
with open(filepath, "rb") as f:
|
|
57
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
58
|
+
h.update(chunk)
|
|
59
|
+
return f"sha256:{h.hexdigest()}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: obsideo-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Obsideo core — the shared client layer for the Obsideo CLI and the mlvault
|
|
2
|
-
extension: storage seam, signing identity, account-level crypto, email-OTP login,
|
|
3
|
-
and config. Client encrypts; Obsideo sees ciphertext only (Principle 1).
|
|
4
|
-
"""
|
|
1
|
+
"""Obsideo core — the shared client layer for the Obsideo CLI and the mlvault
|
|
2
|
+
extension: storage seam, signing identity, account-level crypto, email-OTP login,
|
|
3
|
+
and config. Client encrypts; Obsideo sees ciphertext only (Principle 1).
|
|
4
|
+
"""
|
|
@@ -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()}"
|
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
"""Client-held signing identity (Ed25519).
|
|
2
|
-
|
|
3
|
-
Obsideo external accounts require a customer Ed25519 signing public key — it
|
|
4
|
-
authorizes destructive operations on your data (Principle 2: the network can't
|
|
5
|
-
delete your data without your signature). The PRIVATE half is generated here and
|
|
6
|
-
never leaves your machine; only the public key (obk_sig_…) is sent at signup.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import base64
|
|
10
|
-
|
|
11
|
-
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
12
|
-
from cryptography.hazmat.primitives.serialization import (
|
|
13
|
-
Encoding, NoEncryption, PrivateFormat, PublicFormat,
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
from obsideo_core import config
|
|
17
|
-
|
|
18
|
-
SIGNING_KEY_FILE = config.CONFIG_DIR / "signing.key"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _encode_pub(raw: bytes) -> str:
|
|
22
|
-
return "obk_sig_" + base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def get_or_create_signing_pubkey() -> str:
|
|
26
|
-
"""Return the Ed25519 signing public key as 'obk_sig_<43 chars>',
|
|
27
|
-
generating + persisting the private key locally (0600) on first use."""
|
|
28
|
-
if SIGNING_KEY_FILE.exists():
|
|
29
|
-
raw = bytes.fromhex(SIGNING_KEY_FILE.read_text().strip())
|
|
30
|
-
priv = Ed25519PrivateKey.from_private_bytes(raw)
|
|
31
|
-
else:
|
|
32
|
-
priv = Ed25519PrivateKey.generate()
|
|
33
|
-
raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
|
34
|
-
config.write_secret_file(SIGNING_KEY_FILE, raw.hex())
|
|
35
|
-
pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
36
|
-
return _encode_pub(pub)
|
|
1
|
+
"""Client-held signing identity (Ed25519).
|
|
2
|
+
|
|
3
|
+
Obsideo external accounts require a customer Ed25519 signing public key — it
|
|
4
|
+
authorizes destructive operations on your data (Principle 2: the network can't
|
|
5
|
+
delete your data without your signature). The PRIVATE half is generated here and
|
|
6
|
+
never leaves your machine; only the public key (obk_sig_…) is sent at signup.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
12
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
13
|
+
Encoding, NoEncryption, PrivateFormat, PublicFormat,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from obsideo_core import config
|
|
17
|
+
|
|
18
|
+
SIGNING_KEY_FILE = config.CONFIG_DIR / "signing.key"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _encode_pub(raw: bytes) -> str:
|
|
22
|
+
return "obk_sig_" + base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_or_create_signing_pubkey() -> str:
|
|
26
|
+
"""Return the Ed25519 signing public key as 'obk_sig_<43 chars>',
|
|
27
|
+
generating + persisting the private key locally (0600) on first use."""
|
|
28
|
+
if SIGNING_KEY_FILE.exists():
|
|
29
|
+
raw = bytes.fromhex(SIGNING_KEY_FILE.read_text().strip())
|
|
30
|
+
priv = Ed25519PrivateKey.from_private_bytes(raw)
|
|
31
|
+
else:
|
|
32
|
+
priv = Ed25519PrivateKey.generate()
|
|
33
|
+
raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
|
34
|
+
config.write_secret_file(SIGNING_KEY_FILE, raw.hex())
|
|
35
|
+
pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
36
|
+
return _encode_pub(pub)
|
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
"""Deterministic filename encryption (Level 1 metadata privacy).
|
|
2
|
-
|
|
3
|
-
Object keys (folder paths + filenames) would otherwise reach Obsideo in the clear,
|
|
4
|
-
leaking the *shape* of your data even though contents are encrypted. This encrypts
|
|
5
|
-
each path component client-side with **AES-SIV** (deterministic, misuse-resistant
|
|
6
|
-
authenticated encryption) keyed off your data key, so `photos/tax.pdf` is stored
|
|
7
|
-
as opaque tokens.
|
|
8
|
-
|
|
9
|
-
Why deterministic: identical input → identical token, which is what lets the
|
|
10
|
-
server still do prefix listing — `ls` lists under the encrypted prefix and the
|
|
11
|
-
client decrypts the returned tokens back to real names. Residual leak (accepted at
|
|
12
|
-
Level 1): the same name encrypts to the same token, so Obsideo can see that two
|
|
13
|
-
objects share a name, never what it is; structure + sizes still show.
|
|
14
|
-
|
|
15
|
-
SCHEME (so other clients — e.g. the SDK — can interop, same account same names):
|
|
16
|
-
name_key = HKDF-SHA256(ikm=data_key, salt="", info="obsideo-name-key-v1", len=64)
|
|
17
|
-
token = base64url-nopad( AES-SIV-encrypt(name_key, utf8(component), aad=none) )
|
|
18
|
-
path = "/".join(token(c) for c in path.split("/") if c)
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
import base64
|
|
22
|
-
|
|
23
|
-
from cryptography.hazmat.primitives import hashes
|
|
24
|
-
from cryptography.hazmat.primitives.ciphers.aead import AESSIV
|
|
25
|
-
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
26
|
-
|
|
27
|
-
from obsideo_core import crypto
|
|
28
|
-
|
|
29
|
-
_INFO = b"obsideo-name-key-v1"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _name_key() -> bytes:
|
|
33
|
-
# Derived fresh from the data key (cheap); stays correct if the key changes.
|
|
34
|
-
return HKDF(algorithm=hashes.SHA256(), length=64, salt=None, info=_INFO).derive(crypto.data_key())
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _b64e(b: bytes) -> str:
|
|
38
|
-
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _b64d(s: str) -> bytes:
|
|
42
|
-
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def encrypt_name(name: str) -> str:
|
|
46
|
-
return _b64e(AESSIV(_name_key()).encrypt(name.encode(), None))
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def decrypt_name(token: str) -> str:
|
|
50
|
-
return AESSIV(_name_key()).decrypt(_b64d(token), None).decode()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def encrypt_path(path: str) -> str:
|
|
54
|
-
"""Encrypt each '/'-separated component. Empty -> empty (root)."""
|
|
55
|
-
return "/".join(encrypt_name(c) for c in path.split("/") if c)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def safe_decrypt_name(token: str) -> tuple[str, bool]:
|
|
59
|
-
"""Decrypt a listed token. Returns (name, was_encrypted). Falls back to the
|
|
60
|
-
raw token for legacy clear-name objects (so listings never crash on a mixed
|
|
61
|
-
account)."""
|
|
62
|
-
try:
|
|
63
|
-
return decrypt_name(token), True
|
|
64
|
-
except Exception:
|
|
65
|
-
return token, False
|
|
1
|
+
"""Deterministic filename encryption (Level 1 metadata privacy).
|
|
2
|
+
|
|
3
|
+
Object keys (folder paths + filenames) would otherwise reach Obsideo in the clear,
|
|
4
|
+
leaking the *shape* of your data even though contents are encrypted. This encrypts
|
|
5
|
+
each path component client-side with **AES-SIV** (deterministic, misuse-resistant
|
|
6
|
+
authenticated encryption) keyed off your data key, so `photos/tax.pdf` is stored
|
|
7
|
+
as opaque tokens.
|
|
8
|
+
|
|
9
|
+
Why deterministic: identical input → identical token, which is what lets the
|
|
10
|
+
server still do prefix listing — `ls` lists under the encrypted prefix and the
|
|
11
|
+
client decrypts the returned tokens back to real names. Residual leak (accepted at
|
|
12
|
+
Level 1): the same name encrypts to the same token, so Obsideo can see that two
|
|
13
|
+
objects share a name, never what it is; structure + sizes still show.
|
|
14
|
+
|
|
15
|
+
SCHEME (so other clients — e.g. the SDK — can interop, same account same names):
|
|
16
|
+
name_key = HKDF-SHA256(ikm=data_key, salt="", info="obsideo-name-key-v1", len=64)
|
|
17
|
+
token = base64url-nopad( AES-SIV-encrypt(name_key, utf8(component), aad=none) )
|
|
18
|
+
path = "/".join(token(c) for c in path.split("/") if c)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
|
|
23
|
+
from cryptography.hazmat.primitives import hashes
|
|
24
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESSIV
|
|
25
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
26
|
+
|
|
27
|
+
from obsideo_core import crypto
|
|
28
|
+
|
|
29
|
+
_INFO = b"obsideo-name-key-v1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _name_key() -> bytes:
|
|
33
|
+
# Derived fresh from the data key (cheap); stays correct if the key changes.
|
|
34
|
+
return HKDF(algorithm=hashes.SHA256(), length=64, salt=None, info=_INFO).derive(crypto.data_key())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _b64e(b: bytes) -> str:
|
|
38
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _b64d(s: str) -> bytes:
|
|
42
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def encrypt_name(name: str) -> str:
|
|
46
|
+
return _b64e(AESSIV(_name_key()).encrypt(name.encode(), None))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def decrypt_name(token: str) -> str:
|
|
50
|
+
return AESSIV(_name_key()).decrypt(_b64d(token), None).decode()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def encrypt_path(path: str) -> str:
|
|
54
|
+
"""Encrypt each '/'-separated component. Empty -> empty (root)."""
|
|
55
|
+
return "/".join(encrypt_name(c) for c in path.split("/") if c)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def safe_decrypt_name(token: str) -> tuple[str, bool]:
|
|
59
|
+
"""Decrypt a listed token. Returns (name, was_encrypted). Falls back to the
|
|
60
|
+
raw token for legacy clear-name objects (so listings never crash on a mixed
|
|
61
|
+
account)."""
|
|
62
|
+
try:
|
|
63
|
+
return decrypt_name(token), True
|
|
64
|
+
except Exception:
|
|
65
|
+
return token, False
|
|
@@ -28,6 +28,16 @@ _MULTIPART_CHUNK = 16 * 1024 * 1024 # 16 MiB
|
|
|
28
28
|
# non-empty placeholder object rather than a zero-byte key.
|
|
29
29
|
_FOLDER_MARKER = ".keep"
|
|
30
30
|
|
|
31
|
+
# Windows only: the socket send buffer for upload connections. With the OS
|
|
32
|
+
# default, one connection from a Windows desktop to s3.obsideo.io (62 ms RTT)
|
|
33
|
+
# moved 3.3 MB/s however fast the line was; 1 MiB gave 25-30 MB/s and 4 MiB
|
|
34
|
+
# 25-33 MB/s on the same part (measured 2026-10-17). 4 MiB keeps a far-away
|
|
35
|
+
# uploader (150 ms+) off the same ceiling. Not set elsewhere: on Linux a fixed
|
|
36
|
+
# SO_SNDBUF switches off the kernel's own tuning (and is capped by wmem_max),
|
|
37
|
+
# which can make it slower. OBSIDEO_SEND_BUFFER=<bytes> overrides; 0 leaves
|
|
38
|
+
# the OS default.
|
|
39
|
+
_SEND_BUFFER_WINDOWS = 4 * 1024 * 1024
|
|
40
|
+
|
|
31
41
|
|
|
32
42
|
def _names_on() -> bool:
|
|
33
43
|
return config.load_config().get("encrypt_names", True)
|
|
@@ -100,9 +110,45 @@ def _s3():
|
|
|
100
110
|
|
|
101
111
|
_client = boto3.client("s3", endpoint_url=_endpoint(),
|
|
102
112
|
aws_access_key_id=ak, aws_secret_access_key=sk, config=cfg)
|
|
113
|
+
_widen_send_buffer(_client)
|
|
103
114
|
return _client
|
|
104
115
|
|
|
105
116
|
|
|
117
|
+
def _send_buffer() -> int:
|
|
118
|
+
env = os.environ.get("OBSIDEO_SEND_BUFFER", "").strip()
|
|
119
|
+
if env:
|
|
120
|
+
try:
|
|
121
|
+
return max(0, int(env))
|
|
122
|
+
except ValueError:
|
|
123
|
+
pass
|
|
124
|
+
return _SEND_BUFFER_WINDOWS if os.name == "nt" else 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _widen_send_buffer(client) -> None:
|
|
128
|
+
"""Give the client's connections a larger SO_SNDBUF (see _SEND_BUFFER_WINDOWS).
|
|
129
|
+
|
|
130
|
+
botocore has no public setting for socket options, so this adds one to the
|
|
131
|
+
options its connection pool already uses (TCP_NODELAY, keepalive). Done
|
|
132
|
+
before the first request, so every connection gets it. If botocore's
|
|
133
|
+
internals ever move, this does nothing and uploads run at the old speed;
|
|
134
|
+
it never fails a call.
|
|
135
|
+
"""
|
|
136
|
+
size = _send_buffer()
|
|
137
|
+
if not size:
|
|
138
|
+
return
|
|
139
|
+
import socket
|
|
140
|
+
try:
|
|
141
|
+
sess = client._endpoint.http_session
|
|
142
|
+
opts = [o for o in (sess._socket_options or []) if o[:2] != (socket.SOL_SOCKET, socket.SO_SNDBUF)]
|
|
143
|
+
opts.append((socket.SOL_SOCKET, socket.SO_SNDBUF, size))
|
|
144
|
+
sess._socket_options = opts
|
|
145
|
+
managers = [sess._manager, *getattr(sess, "_proxy_managers", {}).values()]
|
|
146
|
+
for m in managers:
|
|
147
|
+
m.connection_pool_kw["socket_options"] = opts
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
106
152
|
def reset_client() -> None:
|
|
107
153
|
"""Drop the cached client (e.g. after login swaps credentials)."""
|
|
108
154
|
global _client
|
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=61"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "obsideo-cli"
|
|
7
|
-
version = "0.
|
|
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
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.10"
|
|
11
|
-
license = {text = "MIT"}
|
|
12
|
-
keywords = [
|
|
13
|
-
"encrypted storage", "ai agents", "agent storage", "s3", "backup",
|
|
14
|
-
"end-to-end encryption", "object storage", "free tier", "cli", "sync",
|
|
15
|
-
]
|
|
16
|
-
dependencies = [
|
|
17
|
-
"boto3>=1.28",
|
|
18
|
-
"cryptography>=41.0",
|
|
19
|
-
"certifi",
|
|
20
|
-
]
|
|
21
|
-
|
|
22
|
-
[project.urls]
|
|
23
|
-
Homepage = "https://obsideo.io"
|
|
24
|
-
Documentation = "https://obsideo.io/docs/"
|
|
25
|
-
"Agent integration (AGENTS.md)" = "https://obsideo.io/agents.md"
|
|
26
|
-
|
|
27
|
-
[project.scripts]
|
|
28
|
-
obsideo = "obsideo.cli:main"
|
|
29
|
-
obsideo-cli = "obsideo.cli:main"
|
|
30
|
-
|
|
31
|
-
[tool.setuptools.packages.find]
|
|
32
|
-
where = ["."]
|
|
33
|
-
include = ["obsideo*", "obsideo_core*"]
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "obsideo-cli"
|
|
7
|
+
version = "0.6.1"
|
|
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
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
keywords = [
|
|
13
|
+
"encrypted storage", "ai agents", "agent storage", "s3", "backup",
|
|
14
|
+
"end-to-end encryption", "object storage", "free tier", "cli", "sync",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"boto3>=1.28",
|
|
18
|
+
"cryptography>=41.0",
|
|
19
|
+
"certifi",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://obsideo.io"
|
|
24
|
+
Documentation = "https://obsideo.io/docs/"
|
|
25
|
+
"Agent integration (AGENTS.md)" = "https://obsideo.io/agents.md"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
obsideo = "obsideo.cli:main"
|
|
29
|
+
obsideo-cli = "obsideo.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|
|
33
|
+
include = ["obsideo*", "obsideo_core*"]
|
|
@@ -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
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Upload speed: the Windows send buffer."""
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from obsideo_core import config, storage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def creds(monkeypatch, tmp_path):
|
|
12
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
|
|
13
|
+
monkeypatch.setenv("OBSIDEO_S3_ACCESS_KEY", "AKTEST")
|
|
14
|
+
monkeypatch.setenv("OBSIDEO_S3_SECRET_KEY", "secret")
|
|
15
|
+
monkeypatch.setenv("OBSIDEO_S3_BUCKET", "obsideo")
|
|
16
|
+
storage.reset_client()
|
|
17
|
+
yield tmp_path
|
|
18
|
+
storage.reset_client()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_send_buffer_windows_only(monkeypatch):
|
|
22
|
+
monkeypatch.delenv("OBSIDEO_SEND_BUFFER", raising=False)
|
|
23
|
+
monkeypatch.setattr(storage.os, "name", "nt")
|
|
24
|
+
assert storage._send_buffer() == 4 * 1024 * 1024
|
|
25
|
+
monkeypatch.setattr(storage.os, "name", "posix")
|
|
26
|
+
assert storage._send_buffer() == 0
|
|
27
|
+
monkeypatch.setenv("OBSIDEO_SEND_BUFFER", "1048576")
|
|
28
|
+
assert storage._send_buffer() == 1048576
|
|
29
|
+
monkeypatch.setenv("OBSIDEO_SEND_BUFFER", "0")
|
|
30
|
+
monkeypatch.setattr(storage.os, "name", "nt")
|
|
31
|
+
assert storage._send_buffer() == 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_client_connections_get_the_send_buffer(creds, monkeypatch):
|
|
35
|
+
monkeypatch.setenv("OBSIDEO_SEND_BUFFER", "2097152")
|
|
36
|
+
sess = storage._s3()._endpoint.http_session
|
|
37
|
+
want = (socket.SOL_SOCKET, socket.SO_SNDBUF, 2097152)
|
|
38
|
+
assert want in sess._socket_options
|
|
39
|
+
assert want in sess._manager.connection_pool_kw["socket_options"]
|
|
40
|
+
# botocore's own options (TCP_NODELAY) are kept
|
|
41
|
+
assert any(o[1] == socket.TCP_NODELAY for o in sess._socket_options)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_widen_never_raises_on_unknown_client(monkeypatch):
|
|
45
|
+
monkeypatch.setenv("OBSIDEO_SEND_BUFFER", "1024")
|
|
46
|
+
storage._widen_send_buffer(object())
|
|
@@ -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
|