Skip to content

Commit 00a9be7

Browse files
Continue past unreadable encrypted relay bundles
1 parent fe3850a commit 00a9be7

4 files changed

Lines changed: 84 additions & 20 deletions

File tree

SECURITY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ repository or its release artifacts.
150150
use reverse proxy for multi-process/distributed
151151
- Encryption at rest is opt-in for the local memory DB (SQLCipher via
152152
`ENGRAPHIS_DB_KEY`). Protect customer credential state and backups separately.
153-
- Managed relay bundles are HTTPS-protected in transit but remain plaintext at rest until
154-
client-side end-to-end encryption ships. `secret` memories are never uploaded.
153+
- Cloud Sync requires an authorized device to hold its workspace encryption key. The relay cannot
154+
recover a lost key or decrypt its ciphertext; `secret` memories are never uploaded.
155155
- Per-token scope/tenant authorization is partial: isolate distinct tenants by running
156156
one instance each
157157
- Legacy v1 REST server/dashboard is a compatibility surface; prefer v2/MCP path

docs/SYNC.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,10 @@ outside the authorized workspace merely by changing bundle fields.
128128

129129
- Local-only installations send no memory content to Engraphis. **Cloud Sync encrypts eligible
130130
shared-workspace changes end-to-end before they leave this device. Engraphis Cloud cannot read
131-
their contents; secret and session-scoped memories stay local.** Managed compute is a separate,
132-
opt-in service: it sends a readable, bounded snapshot over TLS because Engraphis Cloud must
133-
process that snapshot to produce results.
131+
their contents; secret and session-scoped memories stay local.** Managed compute is separate:
132+
connecting an installation to Engraphis Cloud accepts its terms and enables it by default;
133+
operators may opt out with `ENGRAPHIS_MANAGED_COMPUTE_CONSENT=0`. It sends a readable, bounded
134+
snapshot over TLS because Engraphis Cloud must process that snapshot to produce results.
134135
- Treat cloud session and refresh files as credentials; keep their directory owner-only.
135136
- `secret` memories are excluded from managed uploads. Managed compute also rejects secret rows
136137
server-side.

engraphis/backends/sync_relay.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -415,22 +415,42 @@ def push(self, name: str, data: bytes) -> None:
415415
self.relay.push(stored_name, SYNC_E2EE_MAGIC + nonce + ciphertext)
416416

417417
def pull(self) -> Iterable[Tuple[str, bytes]]:
418+
"""Yield every authentic bundle and flag an incomplete encrypted round.
419+
420+
A Cloud Sync workspace may contain bundles written before E2EE existed, or a
421+
relay object may have been damaged. Neither is eligible for plaintext
422+
fallback, but neither may prevent a later authenticated peer bundle from
423+
being applied. Match ``RelayTransport.pull``: fail closed for each bad
424+
object, yield every valid one, then raise one sanitized error so
425+
``SyncEngine`` reports an incomplete round rather than a false success.
426+
"""
427+
skipped = 0
418428
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:]
429429
try:
430+
safe = _safe_bundle_name(name)
431+
if not safe or not isinstance(data, (bytes, bytearray)):
432+
raise RelayError("relay returned an invalid encrypted bundle")
433+
raw = bytes(data)
434+
if not raw.startswith(SYNC_E2EE_MAGIC):
435+
raise RelayError("relay bundle requires end-to-end encryption")
436+
payload = raw[len(SYNC_E2EE_MAGIC):]
437+
if len(payload) < SYNC_E2EE_NONCE_BYTES + SYNC_E2EE_TAG_BYTES:
438+
raise RelayError("bundle could not be authenticated")
439+
nonce = payload[:SYNC_E2EE_NONCE_BYTES]
440+
ciphertext = payload[SYNC_E2EE_NONCE_BYTES:]
430441
plaintext = self._cipher.decrypt(nonce, ciphertext, self._aad(safe))
431442
except self._invalid_tag:
432-
raise RelayError("bundle could not be authenticated") from None
443+
skipped += 1
444+
continue
445+
except RelayError:
446+
skipped += 1
447+
continue
433448
yield safe, plaintext
449+
if skipped:
450+
raise RelayError(
451+
"encrypted relay skipped %d unreadable bundle%s this round"
452+
% (skipped, "" if skipped == 1 else "s")
453+
)
434454

435455
def list_names(self) -> List[str]:
436456
return self.relay.list_names()

tests/test_sync_e2ee.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,11 @@ def test_cloud_sync_rejects_tampered_or_plaintext_bundle():
6262
name, stored = next(iter(relay.bundles.items()))
6363
relay.bundles[name] = stored[:-1] + bytes([stored[-1] ^ 1])
6464

65-
with pytest.raises(RelayError, match="could not be authenticated"):
65+
with pytest.raises(RelayError, match="unreadable bundle"):
6666
list(receiver.pull())
6767

6868
relay.bundles[name] = b'{"legacy":"plaintext"}'
69-
with pytest.raises(RelayError, match="requires end-to-end encryption"):
69+
with pytest.raises(RelayError, match="unreadable bundle"):
7070
list(receiver.pull())
7171

7272

@@ -76,16 +76,59 @@ def test_cloud_sync_rejects_a_bundle_from_another_key_or_workspace():
7676
wrong_key = _transport(relay, 4)
7777
sender.push("bundle-dev_a.json", b"private content")
7878

79-
with pytest.raises(RelayError, match="could not be authenticated"):
79+
with pytest.raises(RelayError, match="unreadable bundle"):
8080
list(wrong_key.pull())
8181

8282
wrong_workspace = _transport(_MemoryRelay("other"), 3)
8383
name, stored = next(iter(relay.bundles.items()))
8484
wrong_workspace.relay.bundles[name] = stored
85-
with pytest.raises(RelayError, match="could not be authenticated"):
85+
with pytest.raises(RelayError, match="unreadable bundle"):
8686
list(wrong_workspace.pull())
8787

8888

89+
@pytest.mark.parametrize("bad_kind", ["legacy", "tampered"], ids=["legacy", "tampered"])
90+
def test_sync_engine_applies_later_encrypted_bundle_after_unreadable_relay_object(bad_kind):
91+
"""Legacy/corrupt relay objects cannot starve later authenticated peers."""
92+
relay = _MemoryRelay()
93+
key = bytes(range(32))
94+
sender = MemoryEngine.create(":memory:")
95+
receiver = MemoryEngine.create(":memory:")
96+
sender_workspace = sender.store.get_or_create_workspace("acme")
97+
receiver_workspace = receiver.store.get_or_create_workspace("acme")
98+
sender.remember("peer fact survives a bad relay object", workspace_id=sender_workspace,
99+
scope=Scope.WORKSPACE)
100+
sender_sync = SyncEngine(sender.store, embedder=sender.embedder, vector_index=sender.index)
101+
receiver_sync = SyncEngine(
102+
receiver.store, embedder=receiver.embedder, vector_index=receiver.index
103+
)
104+
105+
# The bad object is deliberately inserted before the sender's encrypted bundle.
106+
if bad_kind == "legacy":
107+
relay.bundles["bundle-legacy.json"] = b'{"legacy":"plaintext"}'
108+
else:
109+
corrupt_writer = EncryptedRelayTransport(relay, key)
110+
corrupt_writer.push("bundle-corrupt.json", b"original authenticated ciphertext")
111+
corrupt_name, ciphertext = next(iter(relay.bundles.items()))
112+
relay.bundles[corrupt_name] = ciphertext[:-1] + bytes([ciphertext[-1] ^ 1])
113+
sender_sync.sync(EncryptedRelayTransport(relay, key), sender_workspace)
114+
115+
report = receiver_sync.sync(
116+
EncryptedRelayTransport(relay, key), receiver_workspace, push=False
117+
)
118+
119+
contents = {
120+
memory.content
121+
for memory in receiver.store.list_memories(SearchFilter(workspace_id=receiver_workspace))
122+
}
123+
assert contents == {"peer fact survives a bad relay object"}
124+
assert report["totals"]["added"] == 1
125+
assert report["peers_applied"] == 1
126+
assert report["complete"] is False
127+
assert report["errors"] == [
128+
{"bundle": "?", "error": "transport failure", "error_type": "RelayError"}
129+
]
130+
131+
89132
def test_sync_engine_converges_through_encrypted_relay_without_plaintext_storage():
90133
relay = _MemoryRelay()
91134
key = bytes(range(32))

0 commit comments

Comments
 (0)