obsideo-cli 0.6.0__tar.gz → 0.6.1.post1__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.6.0 → obsideo_cli-0.6.1.post1}/PKG-INFO +3 -2
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/__init__.py +1 -1
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/__main__.py +4 -4
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/manifest.py +59 -59
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/PKG-INFO +3 -2
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/SOURCES.txt +2 -1
- obsideo_cli-0.6.1.post1/obsideo_cli.egg-info/requires.txt +4 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/__init__.py +4 -4
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/identity.py +36 -36
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/names.py +65 -65
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/storage.py +46 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/pyproject.toml +6 -2
- obsideo_cli-0.6.1.post1/tests/test_send_buffer.py +46 -0
- obsideo_cli-0.6.0/obsideo_cli.egg-info/requires.txt +0 -3
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/LICENSE +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/README.md +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/cli.py +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo/sync.py +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/dependency_links.txt +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/entry_points.txt +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_cli.egg-info/top_level.txt +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/config.py +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/crypto.py +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/obsideo_core/login.py +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/setup.cfg +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/tests/test_cli.py +0 -0
- {obsideo_cli-0.6.0 → obsideo_cli-0.6.1.post1}/tests/test_core.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: obsideo-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1.post1
|
|
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
|
|
@@ -10,7 +10,8 @@ Keywords: encrypted storage,ai agents,agent storage,s3,backup,end-to-end encrypt
|
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: boto3>=1.
|
|
13
|
+
Requires-Dist: boto3>=1.42.43
|
|
14
|
+
Requires-Dist: botocore>=1.42.43
|
|
14
15
|
Requires-Dist: cryptography>=41.0
|
|
15
16
|
Requires-Dist: certifi
|
|
16
17
|
Dynamic: license-file
|
|
@@ -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()
|
|
@@ -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.6.
|
|
3
|
+
Version: 0.6.1.post1
|
|
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
|
|
@@ -10,7 +10,8 @@ Keywords: encrypted storage,ai agents,agent storage,s3,backup,end-to-end encrypt
|
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: boto3>=1.
|
|
13
|
+
Requires-Dist: boto3>=1.42.43
|
|
14
|
+
Requires-Dist: botocore>=1.42.43
|
|
14
15
|
Requires-Dist: cryptography>=41.0
|
|
15
16
|
Requires-Dist: certifi
|
|
16
17
|
Dynamic: license-file
|
|
@@ -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
|
+
"""
|
|
@@ -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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "obsideo-cli"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.1.post1"
|
|
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"
|
|
@@ -14,7 +14,11 @@ keywords = [
|
|
|
14
14
|
"end-to-end encryption", "object storage", "free tier", "cli", "sync",
|
|
15
15
|
]
|
|
16
16
|
dependencies = [
|
|
17
|
-
|
|
17
|
+
# botocore 1.42.43+ sends request bodies in 128 KB blocks; older ones use
|
|
18
|
+
# urllib3 2.x's 16 KB, which capped one Windows upload connection at
|
|
19
|
+
# ~3.3 MB/s (measured 2026-10-18). boto3 and botocore share version numbers.
|
|
20
|
+
"boto3>=1.42.43",
|
|
21
|
+
"botocore>=1.42.43",
|
|
18
22
|
"cryptography>=41.0",
|
|
19
23
|
"certifi",
|
|
20
24
|
]
|
|
@@ -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())
|
|
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
|