Product direction: should cross-instance (peering) transfer be client-side E2E? (long-term) #238

Open
opened 2026-08-15 12:07:07 +00:00 by Cordy · 1 comment
Owner

Spun out of the #237 deep-dive as the honest larger question, so it doesn't get conflated with the delivery bug fix.

Cairn today is deliberately server-side transparent encryption: cairnd resolves a principal's age identity per request and encrypts/decrypts in-process (the code states this plainly — "NOT client-side E2E … not protection against a compromised server", ARCHITECTURE §4.3). Per-user isolation is against other users and the storage provider, not against a compromised Cairn instance. #237 fixes peering delivery within that model (encrypt to the recipient's server-held key).

The open question: for cross-instance transfer specifically, should the receiving instance ever see plaintext at all? A zero-trust alternative would have the sender encrypt to the recipient's published public key before it leaves the source instance, so the destination stores ciphertext it cannot read — closer to Signal/MLS/age-to-published-recipient and to Proton/Tresorit's model.

Weigh, long-term, no implementation-cost veto:

  • Trust model. Peering crosses an administrative/trust boundary in a way intra-instance writes don't — arguably the strongest case in the whole product for real E2E. Does that justify a different encryption model for the peered path than for local storage?
  • Key distribution. Requires publishing/fetching recipient public keys across instances (a directory/handshake extension), and pinning/verification to prevent a malicious peer substituting a key. This is the hard part, and it's the same problem MLS/keyservers solve.
  • Feature cost. Ciphertext-at-rest the server can't read forfeits server-side features on delivered files: transparent recovery (the deployment recovery identity), dedupe, scanning, quota-on-plaintext, re-share. Which of these must survive?
  • Consistency. A file that is E2E while in transit but then transparently re-encrypted to the recipient's server key on arrival gains nothing over #237. True benefit only if it stays recipient-only-decryptable at rest — which changes the local model too.
  • PQ. Whatever the scheme, keep the hybrid ML-KEM posture already in place.

Not required for #106 (that's #237). This is a deliberate product-level fork to decide before peering is promoted past closed beta. Refs: #237, #106, #123; ARCHITECTURE.md §4.3; ENCRYPTION-COMPARISON.md.

Spun out of the #237 deep-dive as the honest larger question, so it doesn't get conflated with the delivery bug fix. Cairn today is deliberately **server-side transparent encryption**: `cairnd` resolves a principal's age identity per request and encrypts/decrypts in-process (the code states this plainly — "NOT client-side E2E … not protection against a compromised server", ARCHITECTURE §4.3). Per-user isolation is against *other users and the storage provider*, not against a compromised Cairn instance. #237 fixes peering delivery **within** that model (encrypt to the recipient's server-held key). The open question: for **cross-instance** transfer specifically, should the receiving instance ever see plaintext at all? A zero-trust alternative would have the **sender encrypt to the recipient's published public key** before it leaves the source instance, so the destination stores ciphertext it cannot read — closer to Signal/MLS/`age`-to-published-recipient and to Proton/Tresorit's model. Weigh, long-term, no implementation-cost veto: - **Trust model.** Peering crosses an administrative/trust boundary in a way intra-instance writes don't — arguably the strongest case in the whole product for real E2E. Does that justify a different encryption model for the peered path than for local storage? - **Key distribution.** Requires publishing/fetching **recipient public keys across instances** (a directory/handshake extension), and pinning/verification to prevent a malicious peer substituting a key. This is the hard part, and it's the same problem MLS/keyservers solve. - **Feature cost.** Ciphertext-at-rest the server can't read forfeits server-side features on delivered files: transparent recovery (the deployment recovery identity), dedupe, scanning, quota-on-plaintext, re-share. Which of these must survive? - **Consistency.** A file that is E2E while in transit but then transparently re-encrypted to the recipient's server key on arrival gains nothing over #237. True benefit only if it **stays** recipient-only-decryptable at rest — which changes the local model too. - **PQ.** Whatever the scheme, keep the hybrid ML-KEM posture already in place. Not required for #106 (that's #237). This is a deliberate product-level fork to decide before peering is promoted past closed beta. Refs: #237, #106, #123; ARCHITECTURE.md §4.3; ENCRYPTION-COMPARISON.md.
Author
Owner

How D relates to A — coexistence, the integration seam, and the real forks

Recording this so the decision can be taken later with the concrete integration point in hand.

A and D are two delivery modes, not either/or

  • A (transparent): receiver decrypts the transfer and re-encrypts to the recipient's server-held key. Keeps recovery identity, dedupe, scanning, quota-on-plaintext, receiver-side integrity check.
  • D (E2E): sender encrypts to the recipient's published key; receiver stores ciphertext it can't read.
  • They can live side by side, chosen per-peer or per-transfer, with A as the fallback whenever the recipient has no published key or a policy requires server-side features. A does not block D and D does not remove A.

The seam D plugs into already exists

The handshake already has the sender encrypt to an age recipient that the receiver supplies at PrepareTransfer — today a throwaway per-transfer keypair the receiver mints (inbound.go mintAge) and later decrypts (deliver.go). The sender side is generic: client.go already does age.Encrypt(…, parseRecipient(prep.GetAgeRecipient())).

So D is a natural evolution, not a teardown:

  • Receiver: return the recipient's real published recipient in PrepareResponse.age_recipient instead of a throwaway one; on CompleteTransfer, store the staged blob as-is (skip decrypt + re-encrypt).
  • Sender: unchanged in shape — it already encrypts to whatever recipient the receiver hands back. Only the target changes.
  • D reuses A's foundation: both need "resolve the recipient's key by username on the receiver." A resolves the private half (to re-encrypt); D publishes the public half at PrepareTransfer.

Where D is a genuine change (not a pure add-on)

  1. Replaces the receiver's decrypt-and-re-encrypt step (A's contribution) with store-as-is on the E2E path.
  2. Receiver can no longer decrypt → the plaintext-hash integrity check moves to the recipient (on read) or is dropped. Decide where integrity lives.
  3. At-rest fork: D only delivers zero-trust value if the file stays recipient-only-decryptable at rest. If the receiver re-encrypts to the recipient's server key on arrival, D adds nothing over A. Staying E2E at rest means giving up the deployment recovery identity and server-side features on delivered files.

The hard part, and the decisions to take

  • Cross-instance public-key publication + pinning/verification — a directory/handshake extension so the source can fetch the recipient's real recipient, with protection against a malicious peer substituting a key (the MLS/keyserver problem). This is the substantive work; the delivery plumbing is comparatively small.
  • Explicit choices before promoting D past beta: (a) E2E-at-rest vs re-encrypt-on-arrival; (b) recovery/escrow policy for E2E files (some regimes will require recoverability, which is in tension with E2E); (c) where integrity verification lives; (d) mode selection granularity (per-peer policy vs per-send).

Net: ship A to make peering actually move files under the current model; adopt D later as the stricter mode built on A's resolution + the existing per-transfer-recipient seam, once (a)–(d) are decided. Refs: #237 (A), #106; client.go, inbound.go, deliver.go, PrepareResponse.age_recipient.

## How D relates to A — coexistence, the integration seam, and the real forks Recording this so the decision can be taken later with the concrete integration point in hand. ### A and D are two delivery *modes*, not either/or - **A (transparent):** receiver decrypts the transfer and re-encrypts to the recipient's *server-held* key. Keeps recovery identity, dedupe, scanning, quota-on-plaintext, receiver-side integrity check. - **D (E2E):** sender encrypts to the recipient's *published* key; receiver stores ciphertext it can't read. - They can live side by side, chosen per-peer or per-transfer, with **A as the fallback** whenever the recipient has no published key or a policy requires server-side features. A does not block D and D does not remove A. ### The seam D plugs into already exists The handshake **already** has the sender encrypt to an age recipient that the *receiver supplies* at PrepareTransfer — today a throwaway per-transfer keypair the receiver mints (`inbound.go` `mintAge`) and later decrypts (`deliver.go`). The sender side is generic: `client.go` already does `age.Encrypt(…, parseRecipient(prep.GetAgeRecipient()))`. So D is a natural evolution, not a teardown: - **Receiver:** return the **recipient's real published recipient** in `PrepareResponse.age_recipient` instead of a throwaway one; on CompleteTransfer, **store the staged blob as-is** (skip decrypt + re-encrypt). - **Sender:** unchanged in shape — it already encrypts to whatever recipient the receiver hands back. Only the *target* changes. - D **reuses A's foundation**: both need "resolve the recipient's key by username on the receiver." A resolves the *private* half (to re-encrypt); D publishes the *public* half at PrepareTransfer. ### Where D is a genuine change (not a pure add-on) 1. **Replaces** the receiver's decrypt-and-re-encrypt step (A's contribution) with store-as-is on the E2E path. 2. Receiver can no longer decrypt → the **plaintext-hash integrity check moves to the recipient** (on read) or is dropped. Decide where integrity lives. 3. **At-rest fork:** D only delivers zero-trust value if the file *stays* recipient-only-decryptable at rest. If the receiver re-encrypts to the recipient's server key on arrival, **D adds nothing over A**. Staying E2E at rest means giving up the **deployment recovery identity** and server-side features *on delivered files*. ### The hard part, and the decisions to take - **Cross-instance public-key publication + pinning/verification** — a directory/handshake extension so the source can fetch the recipient's real recipient, with protection against a malicious peer substituting a key (the MLS/keyserver problem). This is the substantive work; the delivery plumbing is comparatively small. - Explicit choices before promoting D past beta: (a) E2E-at-rest vs re-encrypt-on-arrival; (b) recovery/escrow policy for E2E files (some regimes will *require* recoverability, which is in tension with E2E); (c) where integrity verification lives; (d) mode selection granularity (per-peer policy vs per-send). **Net:** ship **A** to make peering actually move files under the current model; adopt **D** later as the stricter mode built on A's resolution + the existing per-transfer-recipient seam, once (a)–(d) are decided. Refs: #237 (A), #106; `client.go`, `inbound.go`, `deliver.go`, `PrepareResponse.age_recipient`.
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#238
No description provided.