Skip to content

Commit 0661944

Browse files
feat: encrypt Cloud Sync bundles end to end
1 parent 59af9bd commit 0661944

14 files changed

Lines changed: 325 additions & 23 deletions

File tree

‎.claude-plugin/marketplace.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"name": "engraphis-memory",
1010
"source": "./",
1111
"description": "Discipline for giving agents durable, scoped, explainable memory across sessions and repos with the Engraphis MCP tools.",
12-
"version": "1.2.0"
12+
"version": "1.2.1"
1313
}
1414
]
1515
}

‎.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "engraphis-memory",
3-
"version": "1.2.0",
3+
"version": "1.2.1",
44
"description": "Give agents durable, scoped, explainable memory across sessions and repos via the Engraphis MCP tools. Use when you learn something worth keeping, need prior context before acting, or ask why/how a fact changed. Covers remember/recall, why/timeline, forget/pin/correct, sessions, and code search.",
55
"author": {
66
"name": "The Engraphis Authors",

‎.claude-plugin/skill-assets.sha256‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
81840a090ae1b8b14fff4eac3bf7ff1832760f4806c510070b78687cb15a0f99 .claude-plugin/marketplace.json
2-
db1b72e67e25bc29e3220d75ff5ab03a0ab954ebdbb93dd74d715ca509317a0c .claude-plugin/plugin.json
1+
3fb76735c2b1d2fa0b70ba9554d6c76a1bfae0edef8a5632be18af8fb15447e1 .claude-plugin/marketplace.json
2+
a837836347bdb17330292c071dc2908527435e9f7defa21d6ab4f4f91acaffde .claude-plugin/plugin.json
33
696fe737e83a8d073c8dac77704ada7261332ede2527b4c4d426b1e657e034da skills/engraphis-memory/SKILL.md
44
7ee71fb5ff9bd2b02f50b3ee8dc62f390a0e1bcd849a55739c4a376ac03d9784 skills/engraphis-memory/references/CONVENTIONS.md
55
8aafd2daba872be38ec8d42377e886d795d8941bf7c6a39795937ffc1d1f0d88 skills/engraphis-memory/references/SCOPING.md

‎CHANGELOG.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33
All notable changes to Engraphis are documented here. Format loosely follows
44
[Keep a Changelog](https://keepachangelog.com/); versions use SemVer.
55

6-
## [Unreleased]
6+
## [1.2.1] - 2026-07-30
7+
8+
### Security
9+
10+
- Cloud Sync now encrypts every eligible shared-workspace bundle on the client with
11+
ChaCha20-Poly1305 before upload. The relay receives opaque deterministic bundle names and
12+
ciphertext only; tampered, renamed, cross-workspace, wrong-key, and legacy plaintext bundles
13+
are rejected before the merge engine.
14+
- Cloud Sync requires a client-held 32-byte workspace key and the `cloud-sync` optional runtime.
15+
Missing or malformed encryption configuration stops sync rather than falling back to plaintext.
716

817
### Changed
918

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,12 @@ bundle; links are exported only when both endpoints remain. Inbound bundles cann
655655
overwrite session state. Cloud Sync encrypts eligible shared-workspace changes end-to-end before
656656
they leave this device; the relay stores ciphertext and cannot read bundle contents.
657657

658+
Cloud Sync fails closed without its client-held workspace key: install `engraphis[cloud-sync]`
659+
on Python 3.10+ and set the same 32-byte URL-safe-base64 `ENGRAPHIS_SYNC_E2EE_KEY` on each
660+
authorized device using a secure out-of-band transfer. The relay and Engraphis Cloud never
661+
receive that key. [`docs/SYNC.md`](docs/SYNC.md) includes the key-generation command and the
662+
`--relay-e2ee-key` one-off CLI alternative.
663+
658664
For development, backup interchange, and offline testing, the public client retains an explicit
659665
one-shot folder exchange. That manual primitive is not the official Cloud Sync product and has
660666
no hosted identity, seat, managed-storage, availability, or support guarantees. See

‎docs/SYNC.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ python -m scripts.sync \
6868
--relay https://relay.engraphis.com
6969
```
7070

71+
Cloud Sync is fail-closed: install `engraphis[cloud-sync]` on Python 3.10+ and provision a
72+
32-byte URL-safe-base64 workspace key as `ENGRAPHIS_SYNC_E2EE_KEY` on every authorized device
73+
before the first upload. Generate it once on a trusted device and transfer it only through your
74+
own secure channel; Engraphis Cloud never receives, derives, or recovers this key. For a
75+
one-off command, pass the same value with `--relay-e2ee-key`. A missing or malformed key stops
76+
Cloud Sync rather than uploading a plaintext bundle.
77+
78+
```bash
79+
python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip('='))"
80+
```
81+
7182
The dashboard's **Sync now** action invokes the same customer protocol. The public package does
7283
not run a local auto-sync loop or ship a cron/Task Scheduler wrapper. Hosted automation belongs
7384
to the private service. If the relay denies every attempted shared workspace because the session
@@ -126,6 +137,10 @@ outside the authorized workspace merely by changing bundle fields.
126137
- Cloud Sync's end-to-end encryption applies to sync bundles, not to managed-compute snapshots or
127138
content deliberately submitted to a configured LLM provider. Those processors must be able to
128139
read the submitted content to perform the requested work.
140+
- Cloud Sync uses a fresh ChaCha20-Poly1305 nonce for each upload and authenticates the stored
141+
opaque bundle name plus workspace as associated data. The relay can store or replay ciphertext,
142+
but a tampered, renamed, cross-workspace, wrong-key, or legacy plaintext bundle is rejected
143+
before it reaches the merge engine.
129144
- Device credentials are not seats. Team seats are named organization members managed by the
130145
hosted control plane.
131146
- Revocation and expiry are authoritative server decisions. A locally modified client does not

‎engraphis/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
except PackageNotFoundError: # source tree without an installed distribution
88
# Keep in step with [project] version in pyproject.toml — tests/test_packaging.py
99
# pins the two together so a release cannot ship them out of sync.
10-
__version__ = "1.2.0"
10+
__version__ = "1.2.1"

‎engraphis/backends/sync_folder.py‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
(temp file + ``os.replace``) so a half-written bundle is never observed — the same
1414
mount-safe discipline the rest of the repo uses (AGENTS.md §7).
1515
16-
The managed TLS relay is a different ``SyncTransport`` implementation that plugs in
17-
here unchanged. Client-side end-to-end encryption is a documented follow-up; today's
18-
relay stores opaque but plaintext bundle bytes at rest.
16+
The managed relay is a different ``SyncTransport`` implementation that plugs in here
17+
unchanged. Its client wrapper encrypts each Cloud bundle before upload and decrypts it
18+
only on an authorized device; the relay stores opaque ciphertext bytes.
1919
"""
2020
from __future__ import annotations
2121

@@ -176,16 +176,18 @@ def get_transport(kind: str = "folder", **kw):
176176
name so swapping the folder backend for the managed relay is a config change.
177177
178178
- ``folder`` (default): shared-directory sync. Requires ``root=<shared directory>``.
179-
- ``relay``: the managed Cloud Sync transport (``RelayTransport``). Requires
179+
- ``relay``: the managed Cloud Sync transport (``EncryptedRelayTransport``). Requires
180180
``base_url=<relay root>`` and ``workspace_id=<namespace>`` (use the workspace
181181
*name*, so every authorized device on the account shares one namespace);
182182
``access_token`` is a scoped bearer and ``timeout`` is optional. ``license_key``
183183
remains a temporary call-site alias for a bearer, never a paid key. The token
184-
defaults to the saved per-user sync token.
184+
defaults to the saved per-user sync token. ``e2ee_key`` is a shared 32-byte key
185+
supplied as URL-safe base64 or through ``ENGRAPHIS_SYNC_E2EE_KEY``; it never
186+
reaches the relay and Cloud Sync refuses to run without it.
185187
186188
Both implement the ``SyncTransport`` protocol (``core/interfaces.py``) and plug into
187189
``SyncEngine.sync`` unchanged. ``relay`` is imported lazily so a folder-only install
188-
never pays for it and ``core`` stays dependency-light (the client is stdlib-only)."""
190+
never pays for it and ``core`` stays dependency-light."""
189191
if kind in ("folder", "auto"):
190192
root = kw.get("root")
191193
if not root:
@@ -198,14 +200,19 @@ def get_transport(kind: str = "folder", **kw):
198200
raise ValueError("relay transport requires base_url=<relay root>")
199201
if not workspace_id:
200202
raise ValueError("relay transport requires workspace_id=<namespace>")
201-
from engraphis.backends.sync_relay import RelayTransport
203+
from engraphis.backends.sync_relay import (
204+
EncryptedRelayTransport,
205+
RelayTransport,
206+
configured_sync_e2ee_key,
207+
)
202208
access_token = kw.get("access_token")
203209
if access_token is None:
204210
access_token = kw.get("license_key")
205-
return RelayTransport(
211+
relay = RelayTransport(
206212
base_url,
207213
workspace_id,
208214
access_token=access_token,
209215
timeout=kw.get("timeout", 30.0),
210216
)
217+
return EncryptedRelayTransport(relay, configured_sync_e2ee_key(kw.get("e2ee_key")))
211218
raise ValueError("unknown sync transport %r (have: folder, relay)" % kind)

‎engraphis/backends/sync_relay.py‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import base64
1515
import binascii
1616
import hashlib
17+
import hmac
1718
import ipaddress
1819
import json
1920
import math
@@ -45,6 +46,14 @@
4546
FATAL_PULL_STATUSES = frozenset({401, 402, 403, 429})
4647
MAX_SYNC_TOKEN_BYTES = 8192
4748
MAX_SYNC_POLICY_BYTES = 64
49+
SYNC_E2EE_PROTOCOL = "v1"
50+
# Wire framing is intentionally binary. The relay stays a blind byte store and can never
51+
# mistake an encrypted bundle for its old JSON payload format.
52+
SYNC_E2EE_MAGIC = b"engraphis-sync-e2ee-v1\x00"
53+
SYNC_E2EE_KEY_BYTES = 32
54+
SYNC_E2EE_NONCE_BYTES = 12
55+
SYNC_E2EE_TAG_BYTES = 16
56+
SYNC_E2EE_KEY_ENV = "ENGRAPHIS_SYNC_E2EE_KEY"
4857

4958

5059
class RelayError(RuntimeError):
@@ -63,6 +72,48 @@ class RelayUnreachable(RelayError):
6372
"""
6473

6574

75+
def decode_sync_e2ee_key(value: object) -> bytes:
76+
"""Decode the user-held Cloud Sync key without ever accepting a weak variant.
77+
78+
It is deliberately a URL-safe, unpadded base64 value for exactly 32 random bytes.
79+
The Cloud service never receives this value: operators provision the same value to
80+
each authorized device through their own trusted channel.
81+
"""
82+
raw = str(value or "").strip()
83+
if re.fullmatch(r"[A-Za-z0-9_-]{43}", raw) is None:
84+
raise RelayError(
85+
"Cloud Sync needs a 32-byte end-to-end encryption key in "
86+
+ SYNC_E2EE_KEY_ENV,
87+
status=409,
88+
)
89+
try:
90+
key = base64.b64decode(raw + "=", altchars=b"-_", validate=True)
91+
except (ValueError, binascii.Error):
92+
raise RelayError("Cloud Sync end-to-end encryption key is malformed", status=409) from None
93+
if len(key) != SYNC_E2EE_KEY_BYTES:
94+
raise RelayError("Cloud Sync end-to-end encryption key is malformed", status=409)
95+
return key
96+
97+
98+
def configured_sync_e2ee_key(value: object = None) -> bytes:
99+
"""Return an explicit key or fail closed before a Cloud upload can begin."""
100+
configured = os.environ.get(SYNC_E2EE_KEY_ENV) if value is None else value
101+
return decode_sync_e2ee_key(configured)
102+
103+
104+
def _new_e2ee_cipher(key: bytes):
105+
"""Construct the optional cryptography backend lazily, preserving a NumPy-only core."""
106+
try:
107+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
108+
from cryptography.exceptions import InvalidTag
109+
except ImportError:
110+
raise RelayError(
111+
"Cloud Sync encryption requires the cryptography package (Python 3.10+)",
112+
status=409,
113+
) from None
114+
return ChaCha20Poly1305(key), InvalidTag
115+
116+
66117
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
67118
"""Never forward a relay bearer credential to a redirect target."""
68119

@@ -308,6 +359,83 @@ def _safe_bundle_name(name: object) -> str:
308359
return value
309360

310361

362+
class EncryptedRelayTransport:
363+
"""Client-side AEAD wrapper for the managed Cloud Sync byte relay.
364+
365+
The wrapped relay receives only an opaque deterministic bundle name and a framed
366+
ChaCha20-Poly1305 ciphertext. The key stays on authorized devices; authentication
367+
data binds each ciphertext to both its Cloud workspace and stored name, so moving,
368+
renaming, modifying, or downgrading a bundle fails closed before sync parses it.
369+
"""
370+
371+
def __init__(self, relay, key: bytes) -> None:
372+
workspace_id = str(getattr(relay, "workspace_id", "") or "")
373+
if not workspace_id:
374+
raise ValueError("encrypted relay transport requires a workspace-bound relay")
375+
if not isinstance(key, (bytes, bytearray)) or len(key) != SYNC_E2EE_KEY_BYTES:
376+
raise ValueError("Cloud Sync encryption key must contain exactly 32 bytes")
377+
self.relay = relay
378+
self.workspace_id = workspace_id
379+
self._key = bytes(key)
380+
self._cipher, self._invalid_tag = _new_e2ee_cipher(self._key)
381+
382+
def _opaque_name(self, name: object) -> str:
383+
safe = _safe_bundle_name(name)
384+
if not safe:
385+
raise RelayError("relay bundle name is invalid")
386+
digest = hmac.new(
387+
self._key,
388+
b"engraphis-cloud-sync-e2ee-name-v1\x00"
389+
+ self.workspace_id.encode("utf-8")
390+
+ b"\x00"
391+
+ safe.encode("utf-8"),
392+
hashlib.sha256,
393+
).hexdigest()
394+
return "e2ee-" + digest + ".json"
395+
396+
def _aad(self, stored_name: str) -> bytes:
397+
return (
398+
b"engraphis-cloud-sync-e2ee-v1\x00"
399+
+ self.workspace_id.encode("utf-8")
400+
+ b"\x00"
401+
+ stored_name.encode("ascii")
402+
)
403+
404+
def push(self, name: str, data: bytes) -> None:
405+
if not isinstance(data, (bytes, bytearray)):
406+
raise RelayError("relay bundle data must be bytes")
407+
# The relay cap applies to ciphertext too. Refuse before allocating a large
408+
# encrypted copy rather than relying on the wrapped transport to reject it later.
409+
overhead = len(SYNC_E2EE_MAGIC) + SYNC_E2EE_NONCE_BYTES + SYNC_E2EE_TAG_BYTES
410+
if len(data) > MAX_RELAY_BUNDLE_BYTES - overhead:
411+
raise RelayError("relay bundle exceeded the encrypted upload safety limit")
412+
stored_name = self._opaque_name(name)
413+
nonce = os.urandom(SYNC_E2EE_NONCE_BYTES)
414+
ciphertext = self._cipher.encrypt(nonce, bytes(data), self._aad(stored_name))
415+
self.relay.push(stored_name, SYNC_E2EE_MAGIC + nonce + ciphertext)
416+
417+
def pull(self) -> Iterable[Tuple[str, bytes]]:
418+
for name, data in self.relay.pull():
419+
safe = _safe_bundle_name(name)
420+
if not safe or not isinstance(data, (bytes, bytearray)):
421+
raise RelayError("relay returned an invalid encrypted bundle")
422+
raw = bytes(data)
423+
if not raw.startswith(SYNC_E2EE_MAGIC):
424+
raise RelayError("relay bundle requires end-to-end encryption")
425+
payload = raw[len(SYNC_E2EE_MAGIC):]
426+
if len(payload) < SYNC_E2EE_NONCE_BYTES + SYNC_E2EE_TAG_BYTES:
427+
raise RelayError("bundle could not be authenticated")
428+
nonce, ciphertext = payload[:SYNC_E2EE_NONCE_BYTES], payload[SYNC_E2EE_NONCE_BYTES:]
429+
try:
430+
plaintext = self._cipher.decrypt(nonce, ciphertext, self._aad(safe))
431+
except self._invalid_tag:
432+
raise RelayError("bundle could not be authenticated") from None
433+
yield safe, plaintext
434+
435+
def list_names(self) -> List[str]:
436+
return self.relay.list_names()
437+
438+
311439
class RelayTransport:
312440
"""A ``SyncTransport`` backed by the customer sync relay.
313441

‎engraphis/commercial_manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema": "engraphis-commercial/v2",
3-
"version": "1.2.0",
3+
"version": "1.2.1",
44
"control_plane": "https://api.engraphis.com",
55
"account_portal": "https://api.engraphis.com/account",
66
"billing": {
@@ -87,7 +87,7 @@
8787
"hosted_multi_user_roles": true,
8888
"hosted_team_audit_export": true,
8989
"hosted_scoped_agent_tokens": true,
90-
"end_to_end_encrypted_sync": false,
90+
"end_to_end_encrypted_sync": true,
9191
"sso": false,
9292
"contractual_sla": false
9393
}

0 commit comments

Comments
 (0)