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.
Files changed (27) hide show
  1. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/PKG-INFO +26 -1
  2. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/README.md +25 -0
  3. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/__init__.py +1 -1
  4. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/__main__.py +4 -4
  5. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/cli.py +71 -1
  6. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/manifest.py +59 -59
  7. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/PKG-INFO +26 -1
  8. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/SOURCES.txt +2 -1
  9. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/__init__.py +4 -4
  10. obsideo_cli-0.6.1/obsideo_core/crypto.py +94 -0
  11. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/identity.py +36 -36
  12. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/names.py +65 -65
  13. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/storage.py +46 -0
  14. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/pyproject.toml +33 -33
  15. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/tests/test_cli.py +84 -0
  16. obsideo_cli-0.6.1/tests/test_send_buffer.py +46 -0
  17. obsideo_cli-0.5.0/obsideo_core/crypto.py +0 -50
  18. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/LICENSE +0 -0
  19. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo/sync.py +0 -0
  20. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  21. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/entry_points.txt +0 -0
  22. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/requires.txt +0 -0
  23. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_cli.egg-info/top_level.txt +0 -0
  24. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/config.py +0 -0
  25. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/obsideo_core/login.py +0 -0
  26. {obsideo_cli-0.5.0 → obsideo_cli-0.6.1}/setup.cfg +0 -0
  27. {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.5.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.5.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
@@ -20,4 +20,5 @@ obsideo_core/login.py
20
20
  obsideo_core/names.py
21
21
  obsideo_core/storage.py
22
22
  tests/test_cli.py
23
- tests/test_core.py
23
+ tests/test_core.py
24
+ tests/test_send_buffer.py
@@ -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.5.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