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/__init__.py +1 -0
- obsideo/__main__.py +4 -0
- obsideo/cli.py +568 -0
- obsideo/manifest.py +59 -0
- obsideo/sync.py +122 -0
- obsideo_cli-0.2.0.dist-info/METADATA +95 -0
- obsideo_cli-0.2.0.dist-info/RECORD +17 -0
- obsideo_cli-0.2.0.dist-info/WHEEL +5 -0
- obsideo_cli-0.2.0.dist-info/entry_points.txt +3 -0
- obsideo_cli-0.2.0.dist-info/top_level.txt +2 -0
- obsideo_core/__init__.py +4 -0
- obsideo_core/config.py +120 -0
- obsideo_core/crypto.py +50 -0
- obsideo_core/identity.py +36 -0
- obsideo_core/login.py +58 -0
- obsideo_core/names.py +65 -0
- obsideo_core/storage.py +265 -0
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,,
|
obsideo_core/__init__.py
ADDED
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()}"
|
obsideo_core/identity.py
ADDED
|
@@ -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
|