Skip to content

Commit 6ee1a1b

Browse files
Herklosclaude
andcommitted
[Node] multi-tenant wallet architecture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0c39dfd commit 6ee1a1b

53 files changed

Lines changed: 4971 additions & 2106 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,49 @@ Use when: running tests, debugging test failures, or verifying changes after cod
3636

3737
Trigger: `@agent-test-runner` · Definition: `.claude/agents/test-runner.md`
3838

39+
## Code Conventions
40+
41+
### Enums
42+
Place enums in `<package_root>/enums.py` (e.g. `octobot/enums.py`, `packages/node/octobot_node/enums.py`). Never define enums inline in module files.
43+
44+
### Constants
45+
Place module-wide constants in `<package_root>/constants.py`. Private module constants (used only within one file) may stay in that file, prefixed with `_`.
46+
47+
### Typed errors
48+
Define a typed error hierarchy rather than raising bare `ValueError`/`KeyError`. Pattern:
49+
```python
50+
# errors.py (sibling to the feature module)
51+
class FeatureError(Exception): pass
52+
class SpecificError(FeatureError): pass
53+
```
54+
Re-export from the package `__init__.py`. Use typed catches everywhere — never inspect `str(err).lower()`, and never catch bare `ValueError`/`KeyError` for domain errors.
55+
56+
### TypedDicts for structured dicts
57+
When a dict has a fixed schema (e.g. wallet info returned to callers), define a `typing.TypedDict`. Place it in the module that owns the data, before the class that produces it.
58+
59+
### File locking
60+
Use the `filelock` library (cross-platform, POSIX + Windows) instead of `fcntl`/`msvcrt` branching.
61+
62+
### Import priority in tentacle files
63+
Prefer the installed `tentacles.Services.Interfaces.*` path first; fall back to bare direct imports (build-time fallback). Pattern:
64+
```python
65+
try:
66+
from tentacles.Services.Interfaces.node_api_interface.api.deps import X
67+
except ImportError:
68+
from api.deps import X # type: ignore[no-redef]
69+
```
70+
All files within a tentacle package should use the same priority order.
71+
72+
### Log levels
73+
- `debug`: verbose diagnostics, expected no-ops.
74+
- `info`: normal operational events (startup, shutdown, config loaded).
75+
- `warning`: unexpected but recoverable (auto-unlock skipped, optional feature unavailable).
76+
- `error`: configuration/state errors that affect functionality (wallet missing, key wrong).
77+
- `exception`: unexpected exceptions — always re-raise after logging unless the function is a top-level "best-effort" path that must not crash the caller.
78+
79+
### Shared filter helpers
80+
Filtering logic used in multiple places belongs in a shared utility module (e.g. `workflows_util.py`), not duplicated inline. Name with a verb: `filter_by_wallet`, not `_filter`.
81+
3982
## Documentation
4083

4184
Documentation lives in `docs/content/` and is built with Docusaurus 3. Package docs go under `docs/content/developers/packages/<pkg-name>/`.

docs/content/developers/packages/node.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,17 @@ The browser holds two private keys, entered once in the Settings page and stored
4242
| `USER_RSA_PRIVATE_KEY` | Decrypts result content from the server |
4343
| `USER_ECDSA_PRIVATE_KEY` | Signs task content before submission |
4444

45-
User public keys are not configured on the server. When the browser submits an encrypted task, it derives both public keys from the stored private keys using the Web Crypto API and embeds them in the task payload (`user_rsa_public_key`, `user_ecdsa_public_key`). The server uses those per-task keys to verify the input signature and encrypt the result — which means a single node instance can serve any number of browser users with different keypairs without reconfiguration.
45+
User public keys are not configured on the server. When the browser submits an encrypted task, it derives both public keys from the stored private keys using the Web Crypto API. The ECDSA public key is embedded in the task payload (`user_ecdsa_public_key`) so the server can verify the input signature whenever that task is consumed. The RSA public key is not stored with the task; instead, the browser derives it fresh and includes it in each export-results request, so the server can wrap the AES key specifically for the requesting user. This separation means the ECDSA key is tied to when a task was created, while the RSA encryption key is always the user's current key — key rotation is transparently supported without resubmitting tasks.
4646

4747
The server public keys (`SERVER_RSA_PUBLIC_KEY` and `SERVER_ECDSA_PUBLIC_KEY`) are never entered manually — the browser fetches them on demand from `GET /tasks/server-public-keys`, which derives and returns them from the server's private keys at runtime. The server never loads the user's private keys; the browser never loads the server's private keys.
4848

4949
**Encryption in the browser.** When submitting encrypted tasks the browser performs all cryptographic operations locally using the Web Crypto API (`crypto.subtle`), without sending any key material to the server. The `encryptAndSign` function first fetches the server's RSA public key from `GET /tasks/server-public-keys`, generates a fresh AES-256-GCM key, encrypts the task payload, wraps the AES key with that server RSA public key (RSA-OAEP), then signs the concatenation of ciphertext, wrapped key, and IV with `USER_ECDSA_PRIVATE_KEY`. The ECDSA signature is converted from the IEEE P1363 format that Web Crypto produces to DER format before transmission, because Python's `cryptography` library expects DER.
5050

5151
**Metadata format.** The accompanying metadata envelope carries `ENCRYPTED_AES_KEY_B64`, `IV_B64`, and `SIGNATURE_B64`. For task inputs, `content_metadata` is `base64(JSON)` — the JSON object is serialised then base64-encoded — because it travels as a CSV or API field where a single opaque string is easiest to embed. For task results, `result_metadata` is a plain JSON string; it is stored in the database and consumed by code that already handles JSON, so the extra base64 layer would be noise. Being aware of this distinction matters when building tooling that reads raw database records.
5252

53-
**`encrypted_task` context manager.** This wraps each task execution on the consumer node transparently. On entry it decrypts `task.content` using `TASKS_SERVER_RSA_PRIVATE_KEY` and verifies the signature when `task.content_metadata` is non-null. Signature verification uses the task's own `user_ecdsa_public_key` field first (browser-submitted tasks carry it inline); if absent, falls back to the `TASKS_USER_ECDSA_PUBLIC_KEY` env var (legacy single-user deployments); then falls back to the server's own ECDSA public key (server-generated internal state, signed with `TASKS_SERVER_ECDSA_PRIVATE_KEY`). If decryption fails the context manager logs the error and continues with the original encrypted content — it does not crash the workflow. On exit it restores the original `task.content` and does not touch results.
53+
**`encrypted_task` context manager.** This wraps each task execution on the consumer node transparently. On entry it decrypts `task.content` using `TASKS_SERVER_RSA_PRIVATE_KEY` and verifies the signature when `task.content_metadata` is non-null. Signature verification uses the task's own `user_ecdsa_public_key` field first (browser-submitted tasks carry it inline); if absent, falls back to the `TASKS_USER_ECDSA_PUBLIC_KEY` env var; then falls back to the server's own ECDSA public key (server-generated internal state, signed with `TASKS_SERVER_ECDSA_PRIVATE_KEY`). If decryption fails the context manager logs the error and continues with the original encrypted content — it does not crash the workflow. On exit it restores the original `task.content` and does not touch results.
5454

55-
**Internal state and result encryption.** Between iterations the automation state is stored in DBOS encrypted with `encrypt_task_content` (AES-GCM wrapped with SERVER_RSA_PUBLIC, signed with SERVER_ECDSA_PRIVATE), making it readable only by the server. When completed executions are fetched via the API, the scheduler decrypts the stored state using the `encrypted_task` context manager (SERVER_RSA_PRIVATE + SERVER/USER ECDSA public), then immediately re-encrypts it with `encrypt_task_result` (AES-GCM wrapped with the task's `user_rsa_public_key` field, signed with SERVER_ECDSA_PRIVATE) before returning it. The API surface therefore only ever exposes ciphertext targeted at the specific browser user who submitted the task. Decryption happens in the browser using `USER_RSA_PRIVATE_KEY`, with the signature verified against `SERVER_ECDSA_PUBLIC_KEY`.
55+
**Internal state and result encryption.** Between iterations the automation state is stored in DBOS encrypted with `encrypt_task_content` (AES-GCM wrapped with SERVER_RSA_PUBLIC, signed with SERVER_ECDSA_PRIVATE), making it readable only by the server. When the user explicitly exports completed results, the browser includes its current RSA public PEM in the export-results request body. The scheduler decrypts the stored state using the `encrypted_task` context manager (SERVER_RSA_PRIVATE + SERVER/USER ECDSA public), then re-encrypts it with `encrypt_task_result` (AES-GCM wrapped with the request-supplied RSA public key, signed with SERVER_ECDSA_PRIVATE) before returning it. If no RSA public key is supplied in the request, the scheduler returns the decrypted state as plaintext. Decryption happens in the browser using `USER_RSA_PRIVATE_KEY`, with the signature verified against `SERVER_ECDSA_PUBLIC_KEY`. Because the RSA key comes from the request rather than the task, a user who rotates their keys or exports a task that was originally submitted without encryption both receive correctly encrypted results without resubmitting anything.
5656

5757
**Security boundary with `octobot_flow`.** The `encrypted_task` context manager wraps the call to `octobot_flow`'s `AutomationJob.run()` inside the node's workflow step. Task content is decrypted just before execution on the consumer node that holds the server private keys. From flow's perspective nothing changes — it receives a plaintext `AutomationState` dict and returns an updated one. The flow package has no awareness of encryption, which means the same engine works identically in encrypted node deployments, unencrypted nodes, and standalone bots.
5858

@@ -75,7 +75,19 @@ The server public keys are never distributed manually — the browser fetches th
7575

7676
User key pairs are generated by the browser on first use and stored locally in the Settings page. The browser derives the corresponding public keys from the stored private keys using the Web Crypto API and embeds them in each task at submission time. No user public key configuration is required on the server.
7777

78-
Encryption is opt-in. If the server keys are absent from the environment, the corresponding path is skipped and fields stay plaintext, which is the backward-compatible default.
78+
Encryption is opt-in. If the server keys are absent from the environment, the corresponding path is skipped and fields stay plaintext.
79+
80+
## Wallet security
81+
82+
The node supports multiple wallets — each identified by an EVM address — so that different users can share a single node instance without accessing each other's tasks or credentials. Wallet security rests on two distinct layers that are often confused: the passphrase, which is for authentication, and the at-rest envelope, which is for storage protection.
83+
84+
**Passphrase role.** The passphrase is a per-wallet authentication credential, not an encryption key. When a wallet is registered, the passphrase is hashed with PBKDF2-HMAC-SHA256 at 600,000 iterations and the hash is stored alongside the wallet — the plaintext passphrase is never written anywhere. At login, the incoming passphrase is hashed and compared to the stored digest using a constant-time comparison to prevent timing attacks. This design keeps multi-tenant access control independent of encryption: the node can validate that a user is who they say they are without needing the passphrase for any other purpose.
85+
86+
**Private key storage.** Wallet private keys are stored in plaintext in the wallet list JSON rather than encrypted with the passphrase. This is an intentional tradeoff that enables bot auto-unlock: when the node process starts, the admin bot needs its wallet available immediately without waiting for a human to type a passphrase. Storing the key encrypted with the user passphrase would break unattended startup. The protection for private keys at rest therefore comes from the storage envelope, not the passphrase. When `OCTOBOT_WALLET_AES_KEY` is set, the entire wallet list is wrapped in AES-256-GCM before writing to disk — any attacker who reads the file without the environment-level key sees only ciphertext. Without that env var the wallet list is plaintext JSON, protected only by filesystem permissions. For production deployments, setting `OCTOBOT_WALLET_AES_KEY` and restricting read access to the config file are the primary defenses.
87+
88+
**Per-wallet browser key storage.** The browser-side encryption keys (RSA and ECDSA private keys) that users configure in the Settings page are stored in IndexedDB, encrypted with a key derived from the user's wallet passphrase and address using PBKDF2. The derivation uses the address as a deterministic salt, so each wallet address produces a different encryption key. This has two practical consequences: first, two users sharing a browser cannot read each other's keys even if they can inspect the raw IndexedDB records; second, when a user logs out and the passphrase is cleared from memory, the client keys become inaccessible — they are still physically present in the database but cannot be decrypted without the passphrase. Sessions on new devices or browsers start with no client keys and must re-enter them in Settings.
89+
90+
**Security boundary.** An attacker with read access to the node's disk (but not its running environment) can reach the wallet list file. If `OCTOBOT_WALLET_AES_KEY` is absent, they can extract private keys directly. If the env var is set, they need both the file and the key. Either way, an attacker with both the wallet list and the passphrase hash gains nothing extra — the passphrase hash cannot be used to derive the private key because the private key was never encrypted with the passphrase. For the browser keys, an attacker who can dump the IndexedDB store (e.g. from a local machine compromise) still needs the passphrase for the wallet in question to decrypt them.
7991

8092
## Template importing
8193

0 commit comments

Comments
 (0)