obsideo-cli 0.2.0__py3-none-any.whl

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/sync.py ADDED
@@ -0,0 +1,122 @@
1
+ """Sync a local folder with an Obsideo remote prefix (default 'sync/').
2
+
3
+ Encrypts on push, decrypts on pull (account data key). Tracks state in a local
4
+ manifest so unchanged files are skipped. Adapted from Cloud_Terminal's sync onto
5
+ the Obsideo storage seam.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from obsideo_core import config, crypto, storage
12
+ from obsideo import manifest
13
+
14
+ REMOTE_PREFIX = "sync/"
15
+
16
+
17
+ def _sync_dir() -> Path:
18
+ return Path(config.load_config().get("sync_dir", str(Path.home() / "obsideo-sync")))
19
+
20
+
21
+ def _remote_key(name: str) -> str:
22
+ return f"{REMOTE_PREFIX}{name}"
23
+
24
+
25
+ def sync_status() -> dict:
26
+ sync_dir = _sync_dir()
27
+ entries = manifest.get_all()
28
+ status = {"to_push": [], "to_pull": [], "synced": []}
29
+
30
+ local_files = {}
31
+ if sync_dir.exists():
32
+ local_files = {f.name: f for f in sync_dir.iterdir() if f.is_file()}
33
+
34
+ for name, f in local_files.items():
35
+ local_hash = manifest.file_sha256(f)
36
+ entry = entries.get(name)
37
+ if entry is None or entry.get("local_hash") != local_hash:
38
+ status["to_push"].append(name)
39
+ else:
40
+ status["synced"].append(name)
41
+
42
+ # Remote files we know about but don't have locally.
43
+ try:
44
+ remote = storage.list_prefix(REMOTE_PREFIX)
45
+ remote_names = {f["name"] for f in remote["files"]}
46
+ except Exception:
47
+ remote_names = set(entries.keys())
48
+ for name in remote_names:
49
+ if name not in local_files:
50
+ status["to_pull"].append(name)
51
+
52
+ return status
53
+
54
+
55
+ def push(verbose: bool = True) -> int:
56
+ sync_dir = _sync_dir()
57
+ if not sync_dir.exists():
58
+ if verbose:
59
+ print(f"Sync folder does not exist: {sync_dir}")
60
+ return 0
61
+
62
+ do_encrypt = config.load_config().get("encrypt", True)
63
+ entries = manifest.get_all()
64
+ pushed = 0
65
+
66
+ for f in (p for p in sync_dir.iterdir() if p.is_file()):
67
+ local_hash = manifest.file_sha256(f)
68
+ entry = entries.get(f.name)
69
+ if entry and entry.get("local_hash") == local_hash:
70
+ if verbose:
71
+ print(f" {f.name} - unchanged, skipping")
72
+ continue
73
+
74
+ raw = f.read_bytes()
75
+ body = crypto.encrypt(raw) if do_encrypt else raw
76
+ try:
77
+ key = storage.put(_remote_key(f.name), body)
78
+ manifest.upsert(f.name, remote_key=key, local_hash=local_hash,
79
+ size=len(raw), encrypted=do_encrypt)
80
+ pushed += 1
81
+ if verbose:
82
+ print(f" {f.name} - uploaded")
83
+ except Exception as e:
84
+ print(f" {f.name} - FAILED: {e}", file=sys.stderr)
85
+
86
+ return pushed
87
+
88
+
89
+ def pull(verbose: bool = True) -> int:
90
+ sync_dir = _sync_dir()
91
+ sync_dir.mkdir(parents=True, exist_ok=True)
92
+
93
+ try:
94
+ remote = storage.list_prefix(REMOTE_PREFIX)
95
+ except Exception as e:
96
+ print(f"Failed to list remote: {e}", file=sys.stderr)
97
+ return 0
98
+
99
+ pulled = 0
100
+ for rf in remote["files"]:
101
+ name = rf["name"]
102
+ local_file = sync_dir / name
103
+ try:
104
+ blob = storage.get(rf["key"])
105
+ try:
106
+ raw = crypto.decrypt(blob)
107
+ encrypted = True
108
+ except Exception:
109
+ raw = blob # was stored unencrypted
110
+ encrypted = False
111
+ local_file.parent.mkdir(parents=True, exist_ok=True)
112
+ local_file.write_bytes(raw)
113
+ manifest.upsert(name, remote_key=rf["key"],
114
+ local_hash=manifest.file_sha256(local_file),
115
+ size=len(raw), encrypted=encrypted)
116
+ pulled += 1
117
+ if verbose:
118
+ print(f" {name} - downloaded")
119
+ except Exception as e:
120
+ print(f" {name} - FAILED: {e}", file=sys.stderr)
121
+
122
+ return pulled
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: obsideo-cli
3
+ Version: 0.2.0
4
+ Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal.
5
+ License: MIT
6
+ Project-URL: Homepage, https://obsideo.io
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: boto3>=1.28
10
+ Requires-Dist: cryptography>=41.0
11
+
12
+ # obsideo-cli
13
+
14
+ **Encrypted storage we can't read.** Save, browse, and sync whatever you want
15
+ from your terminal. Files are encrypted on your machine before they leave, so
16
+ Obsideo's gateway, coordinator, and storage providers only ever see ciphertext.
17
+ Your data lands on three independent providers (RF=3).
18
+
19
+ ```
20
+ pip install obsideo-cli
21
+ obsideo login # email -> 3 GB free
22
+ obsideo # open the shell
23
+ ```
24
+
25
+ ## Get started
26
+
27
+ ```
28
+ $ obsideo login
29
+ Enter your email: you@example.com
30
+ Check your email for a verification code.
31
+ Enter verification code: 482913
32
+ You're all set. 3 GB free.
33
+ ```
34
+
35
+ Login is handled by Obsideo's signup service at **`signup.obsideo.io`**: it emails
36
+ you a one-time code and provisions your free tier. There's no password - just your
37
+ email and a local key (see *How it works*).
38
+
39
+ Then either drop into the shell or run one-shot commands:
40
+
41
+ ```
42
+ $ obsideo
43
+ obsideo:/ put ~/notes.txt
44
+ obsideo:/ ls
45
+ [file] notes.txt 1.2 KB
46
+ obsideo:/ put ~/photos # a whole folder, uploaded recursively
47
+ obsideo:/ put "C:\My Files\tax return.pdf" # paths with spaces: just quote them
48
+ obsideo:/ mkdir trip
49
+ obsideo:/ cd trip
50
+ obsideo:/trip/ put ~/cat.jpg
51
+ obsideo:/trip/ get cat.jpg ./downloaded.jpg
52
+ ```
53
+
54
+ ## Commands
55
+
56
+ | Command | Description |
57
+ |---|---|
58
+ | `obsideo login` | Sign up / log in with your email (3 GB free) |
59
+ | `ls [path]` | List files and folders |
60
+ | `cd <path>` / `pwd` | Move around / show location |
61
+ | `put <local> [name]` | Encrypt + upload a file, or a whole folder (recursive). `--no-encrypt` to store as-is |
62
+ | `get <remote> [local]` | Download + decrypt a file |
63
+ | `rm <remote>` | Delete a file |
64
+ | `mkdir <name>` | Create a folder |
65
+ | `info <remote>` | Show object metadata |
66
+ | `account` | Show storage used vs. your free quota |
67
+ | `sync push\|pull\|status` | Sync your local folder with Obsideo |
68
+ | `config [set k v]` | Show or change settings |
69
+
70
+ ## How it works
71
+
72
+ `obsideo` is a thin front-end over the shared **`obsideo_core`** layer (storage
73
+ seam, signing identity, account crypto, email-OTP login). The `mlvault` ML
74
+ extension builds on the same core - build the core once, two front-ends.
75
+
76
+ - **Encryption:** AES-256-GCM with one account data key held locally at
77
+ `~/.obsideo/data.key`. Copy that key to another machine and everything is
78
+ readable there; lose it and the data is unrecoverable by design. Back it up.
79
+ - **Signing identity:** an Ed25519 key (`~/.obsideo/signing.key`) authorizes
80
+ deletes (Principle 2 - the network can't delete your data without your
81
+ signature). Generated locally; only the public half is ever sent.
82
+ - **Filename encryption:** on by default (`encrypt_names`). Each path component
83
+ (folder names + filename) is encrypted on your machine with AES-SIV - deterministic,
84
+ so `ls`/`cd` still list under the encrypted prefix and the client decrypts the
85
+ returned tokens back to real names. Turn it off with `config set encrypt_names false`
86
+ (interop/debug; existing objects aren't migrated).
87
+ - **What Obsideo sees:** ciphertext only - never a filename or a byte of content.
88
+ Residual leaks (by design at this level): directory *structure* (depth, fan-out),
89
+ object *sizes* (ciphertext ≈ plaintext), and object *counts*. Identical names
90
+ encrypt to identical tokens, so Obsideo can tell two objects share a name - never
91
+ what it is.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,17 @@
1
+ obsideo/__init__.py,sha256=ZPShIsPWHXlDcefLQSIXhnTGwX21JmO4KSQ86OYQVHU,80
2
+ obsideo/__main__.py,sha256=TmRjTS_kputEIVdtJpmDI3rl4-adUGLQTBZlaKO3ioU,68
3
+ obsideo/cli.py,sha256=21o1xFd_ZIxRCtbKcsTq5ZJcKde5N0WawblozEhyb0c,22590
4
+ obsideo/manifest.py,sha256=w_7puCrq0-BDI5sZ2VZPqiHScipvMKFtskDZjJtJXHk,1500
5
+ obsideo/sync.py,sha256=hVjE-rSXJxrroHvH-EZ0YbQ3ZD8ARVR9Q1Pr-2zM5PQ,3807
6
+ obsideo_core/__init__.py,sha256=xpfSGgKEuesL945_rq0KnThKaIkDtuhaygTH98jVkFs,239
7
+ obsideo_core/config.py,sha256=IUs2hcTrBjuid0-uyv3_IqUKR7ZQGxR1-maiZl0sjH8,4706
8
+ obsideo_core/crypto.py,sha256=xx4HXDVzTWItp9kdUeMzcRkEiI2RGqbCIyj1FEBmUF8,1632
9
+ obsideo_core/identity.py,sha256=cfPAxeIqcPKA4CEirIhWTLAII1jIAQhk4Jfw8F70GOY,1429
10
+ obsideo_core/login.py,sha256=o_WiJ57Jt1FG0fPpHOZjPL4GFLLaD529_9LB7w7rwls,1907
11
+ obsideo_core/names.py,sha256=MntkjDPgOckKn6vgE4Zh2qAURlxx6jm7d9JxZH97P0s,2495
12
+ obsideo_core/storage.py,sha256=sv-STw0gFAeSNsyXzLcyJ0Wk2MkxKyPaAas2WmA7Pjw,9295
13
+ obsideo_cli-0.2.0.dist-info/METADATA,sha256=Tc53lh92MCKH8f2R4zn4IfhjEJhZ8RKmqURsn77f-cA,3780
14
+ obsideo_cli-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ obsideo_cli-0.2.0.dist-info/entry_points.txt,sha256=dKCugMhoQhpPZuvGDGsw3pGCquJxc2aOPfQOEZKKM-s,76
16
+ obsideo_cli-0.2.0.dist-info/top_level.txt,sha256=eqmkFbh045p9o5Q-Z7zy5aFRVKi4xpFs0iFflygtHZU,21
17
+ obsideo_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ obsideo = obsideo.cli:main
3
+ obsideo-cli = obsideo.cli:main
@@ -0,0 +1,2 @@
1
+ obsideo
2
+ obsideo_core
@@ -0,0 +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
+ """
obsideo_core/config.py ADDED
@@ -0,0 +1,120 @@
1
+ """Config + credential loading for the Obsideo core.
2
+
3
+ Everything lives under ~/.obsideo:
4
+ credentials # OBSIDEO_S3_* + OBSIDEO_ACCOUNT_TOKEN (written by `obsideo login`)
5
+ config.json # user settings (default bucket, encrypt flag, sync dir, cwd)
6
+ data.key # account data-encryption key (see crypto.py)
7
+ signing.key # Ed25519 signing private key (see identity.py)
8
+ """
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ CONFIG_DIR = Path.home() / ".obsideo"
15
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials"
16
+ CONFIG_FILE = CONFIG_DIR / "config.json"
17
+
18
+ DEFAULT_CONFIG = {
19
+ "bucket": "obsideo",
20
+ "encrypt": True, # encrypt file contents
21
+ "encrypt_names": True, # encrypt file/folder names (metadata privacy)
22
+ "sync_dir": str(Path.home() / "obsideo-sync"),
23
+ }
24
+
25
+ _DEFAULT_ENDPOINT = "https://s3.obsideo.io"
26
+ _DEFAULT_REGION = "us-east-1"
27
+
28
+ # Sent on every request to the signup shim. A descriptive User-Agent avoids
29
+ # Cloudflare's default-`Python-urllib` bot block (HTTP 403 / error 1010) that
30
+ # fronts signup.obsideo.io; without it, `obsideo login` and usage lookups fail.
31
+ try:
32
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError
33
+ try:
34
+ _VERSION = _pkg_version("obsideo-cloud")
35
+ except PackageNotFoundError:
36
+ _VERSION = "0.2.0"
37
+ except Exception:
38
+ _VERSION = "0.2.0"
39
+
40
+ USER_AGENT = f"obsideo-cloud/{_VERSION}"
41
+
42
+
43
+ def write_secret_file(path: Path, value: str) -> None:
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ path.write_text(value)
46
+ try:
47
+ os.chmod(path, 0o600)
48
+ except OSError:
49
+ pass # Windows
50
+
51
+
52
+ def _load_env_file(path: Path) -> None:
53
+ """Load key=value pairs into os.environ (no-overwrite)."""
54
+ if not path.exists():
55
+ return
56
+ for line in path.read_text().splitlines():
57
+ line = line.strip()
58
+ if not line or line.startswith("#") or "=" not in line:
59
+ continue
60
+ key, _, val = line.partition("=")
61
+ os.environ.setdefault(key.strip(), val.strip().strip("'\""))
62
+
63
+
64
+ # Load saved credentials into the environment on import (no-overwrite, so explicit
65
+ # env vars still win). storage.py reads OBSIDEO_S3_* from the environment.
66
+ _load_env_file(CREDENTIALS_FILE)
67
+
68
+
69
+ # ── User config ─────────────────────────────────────────────────────────────
70
+
71
+ def load_config() -> dict:
72
+ cfg = dict(DEFAULT_CONFIG)
73
+ if CONFIG_FILE.exists():
74
+ try:
75
+ cfg.update(json.loads(CONFIG_FILE.read_text()))
76
+ except Exception:
77
+ pass
78
+ return cfg
79
+
80
+
81
+ def save_config(cfg: dict) -> None:
82
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
83
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
84
+
85
+
86
+ # ── Credentials ─────────────────────────────────────────────────────────────
87
+
88
+ def write_credentials(creds: dict) -> None:
89
+ """Persist the credential bundle returned by `obsideo login`."""
90
+ lines = [
91
+ f"OBSIDEO_S3_ENDPOINT={creds.get('endpoint', _DEFAULT_ENDPOINT)}",
92
+ f"OBSIDEO_S3_ACCESS_KEY={creds['access_key']}",
93
+ f"OBSIDEO_S3_SECRET_KEY={creds['secret_key']}",
94
+ f"OBSIDEO_S3_BUCKET={creds.get('bucket', DEFAULT_CONFIG['bucket'])}",
95
+ f"OBSIDEO_S3_REGION={creds.get('region', _DEFAULT_REGION)}",
96
+ ]
97
+ if creds.get("account_token"):
98
+ lines.append(f"OBSIDEO_ACCOUNT_TOKEN={creds['account_token']}")
99
+ write_secret_file(CREDENTIALS_FILE, "\n".join(lines) + "\n")
100
+ # Reflect immediately for the current process (write_secret_file already wrote
101
+ # the file; force these into env even if already set from a prior session).
102
+ os.environ["OBSIDEO_S3_ENDPOINT"] = creds.get("endpoint", _DEFAULT_ENDPOINT)
103
+ os.environ["OBSIDEO_S3_ACCESS_KEY"] = creds["access_key"]
104
+ os.environ["OBSIDEO_S3_SECRET_KEY"] = creds["secret_key"]
105
+ os.environ["OBSIDEO_S3_BUCKET"] = creds.get("bucket", DEFAULT_CONFIG["bucket"])
106
+ os.environ["OBSIDEO_S3_REGION"] = creds.get("region", _DEFAULT_REGION)
107
+ if creds.get("account_token"):
108
+ os.environ["OBSIDEO_ACCOUNT_TOKEN"] = creds["account_token"]
109
+
110
+
111
+ def is_logged_in() -> bool:
112
+ return bool(os.environ.get("OBSIDEO_S3_ACCESS_KEY") and os.environ.get("OBSIDEO_S3_SECRET_KEY"))
113
+
114
+
115
+ def account_token() -> str | None:
116
+ return os.environ.get("OBSIDEO_ACCOUNT_TOKEN")
117
+
118
+
119
+ def signup_url() -> str:
120
+ return os.environ.get("OBSIDEO_SIGNUP_URL", "https://signup.obsideo.io")
obsideo_core/crypto.py ADDED
@@ -0,0 +1,50 @@
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()}"
@@ -0,0 +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)
obsideo_core/login.py ADDED
@@ -0,0 +1,58 @@
1
+ """Email-OTP login against the Obsideo signup shim (obsideo-signup).
2
+
3
+ UI-agnostic: callers (the CLI) handle prompting; these functions do the HTTP and
4
+ return data. No new dependencies (stdlib urllib).
5
+ """
6
+
7
+ import json
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ from obsideo_core import config, identity
12
+
13
+
14
+ class LoginError(RuntimeError):
15
+ pass
16
+
17
+
18
+ def _post_json(url: str, payload: dict) -> dict:
19
+ data = json.dumps(payload).encode()
20
+ req = urllib.request.Request(
21
+ url,
22
+ data=data,
23
+ headers={"Content-Type": "application/json", "User-Agent": config.USER_AGENT},
24
+ method="POST",
25
+ )
26
+ try:
27
+ with urllib.request.urlopen(req, timeout=30) as resp:
28
+ return json.loads(resp.read().decode())
29
+ except urllib.error.HTTPError as e:
30
+ try:
31
+ detail = json.loads(e.read().decode()).get("detail", "")
32
+ except Exception:
33
+ detail = ""
34
+ raise LoginError(detail or f"HTTP {e.code}")
35
+ except urllib.error.URLError as e:
36
+ raise LoginError(f"could not reach {url}: {e.reason}")
37
+
38
+
39
+ def start(email: str, url: str | None = None) -> None:
40
+ """Request a verification code be emailed to `email`."""
41
+ url = url or config.signup_url()
42
+ _post_json(f"{url}/v1/auth/start", {"email": email})
43
+
44
+
45
+ def verify(email: str, code: str, url: str | None = None) -> dict:
46
+ """Verify the code + provision an account. Generates the local signing key,
47
+ sends only its public half, persists the returned credentials. Returns the
48
+ credential bundle.
49
+ """
50
+ url = url or config.signup_url()
51
+ signing_pubkey = identity.get_or_create_signing_pubkey()
52
+ creds = _post_json(f"{url}/v1/auth/verify", {
53
+ "email": email,
54
+ "code": code,
55
+ "customer_signing_public_key": signing_pubkey,
56
+ })
57
+ config.write_credentials(creds)
58
+ return creds
obsideo_core/names.py ADDED
@@ -0,0 +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