Peering delivery can't encrypt to a non-session recipient on per-user custody (blocks #106) — deep-dive #237
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#237
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Deep-research / deep-dive. Rank permanent, sound designs; weight correctness, security, and cross-custody consistency. Do not down-weight an option for being harder to implement.
What surfaced
2026-08-14 A↔B dogfood (files/enc ↔ files-bao/openbao), first real end-to-end transfer. Control plane is fully working now — handshake (#102) over the Caddy h2c path, send (#103), and the recipient picker (#125) all succeed. The transfer then fails at delivery on the receiver:
Root cause
FileDeliverer.Deliver(internal/peering/deliver.go) decrypts the staged blob with the per-transfer identity this instance minted, then writes the plaintext through the full decorated storage stack —d.Sink.Write(ctx, …)— precisely so it's re-encrypted to the recipient's own key (IMPLEMENTED-PEERING.md §5: "re-encryption to the recipient's own key … applies with no new code").But the re-encryption decorator derives the target key from the authenticated user in the context, and the
ctxhere is the peeringCompleteTransferRPC context — which has no authenticated user. The peer authenticated over gRPC; the recipient user has no session on the receiving instance. So the encrypt decorator throwsno authenticated user in context, andCompleteTransferreturnsSTATUS_REFUSED.Impact
Peering file delivery does not work on per-user-encrypted instances — i.e. the primary custody modes (keycloak-profile, openbao). The §5 "no new code" promise only holds where the encryption target is session-independent (instance-static / recovery identity). For real per-user custody, delivery needs a capability it doesn't have: resolve and encrypt to the recipient's key without the recipient's session. Handshake + discovery are proven; this is the remaining gate on #106 being genuinely usable rather than handshake-complete.
Deep-dive questions
Define one custody-agnostic "resolve recipient write-recipient" seam.
auth.Usercarrying the resolved recipient, or a delivery-scoped Sink built for that user. Writing as another user is a genuinely new capability; bound it tightly to the delivery path and reason about its blast radius.STATUS_REFUSED. Distinguish "recipient has no provisioned key" / "IdP admin client absent" / "unknown recipient" so sender and the receiver's audit can tell them apart. §4.3 already accepts recipient existence as known.Recipients.Existsgates PrepareTransfer on residency. Does "resident" guarantee a provisioned encryption key? If a resident user can still lack one, PrepareTransfer passes but delivery fails — the two checks must agree, so anything accepted at prepare can always be delivered.Deliverable
A design for recipient-key resolution across custody modes + the delivery-context change, then implement. Note the sharp edge: this is the difference between "peering handshakes" and "peering actually moves files" on the modes people will run.
Refs: #104, #102, #103, #106, #123, #125; IMPLEMENTED-PEERING.md §5;
internal/peering/deliver.go. Blocks #106.Deep-dive from the code (2026-08-14) — the question is narrower than framed
Read the encryption stack end to end (
encrypt.go,crypter.go,keycloak.go,openbao.go,cryptosvc.go,auth/context.go). The architecture already supports encrypting to a non-session recipient — delivery simply never puts the recipient into the write context. Ground truth, then ranked options.What the code actually does
encrypt.Driver).Write→KeyProvider.Recipients(ctx)→ encrypt body to those age recipients.Open→ decrypt withIdentities(ctx).KeycloakKeys.keysFor:u := auth.UserFrom(ctx); nil →errors.New("encrypt: no authenticated user in context")(our exact error). Otherwise a service-account admin client (client_credentials) looks the user up by name and reads/provisions their age identity from the profile attribute.OpenBaoKeys.keysFor: same shape; resolvesusers/<username>from the KV mount with the app Vault token.Both need only
u.Username, both auto-append the deployment Recovery recipient, both lazily provision an identity if the principal has none.cryptosvc) does exactly it —X-Cairn-User: <name>→auth.ContextWithUser(ctx, &auth.User{Username: name})→ resolve that user's keys ("it decrypts on request for any username the caller names, which is exactly the trust the API role already has"). The split-replica client forwardsUserFrom(ctx).UsernameasX-Cairn-User, so anything that sets the context behaves identically in-process and against a--target=cryptoreplica.deliver.go) was built to write through the full decorated stack ("Sink … must be the FULL decorated stack … re-encryption to the recipient's own key applies with no new code") — but callsd.Sink.Write(ctx, …)with the raw CompleteTransfer RPC context, which has no user. So it hits (2)'s nil branch.The bug: delivery omits the recipient from the write context. Nothing else architecturally is missing.
Approaches (weighted security / stability / long-term correctness; quick-patch discounted)
A — Populate the recipient into the delivery context (recommended). In
Deliver, before the Sink calls:ctx = auth.ContextWithUser(ctx, &auth.User{Username: tr.Recipient}).ContextWithUseris exported precisely "for callers that establish identity outside Middleware." Consequences, all correct:/home/<recipient>/Inbox(personal path → user key, not group);resolveKeysetmints/upgrades hybrid ML-KEM identities).Not a patch — it's the design the deliverer already assumed (~2 lines + import). Security note: it makes the deliverer write as the recipient; that authority already exists at the API/crypto role and is bounded to
tr.Recipient, itself gated by the #123 residency + allow-list check at PrepareTransfer.B — New explicit
KeyProvider.RecipientsFor(username)+ delivery-only write path. More self-documenting (no synthetic user in context) but duplicates resolution, bypasses the crypto-replica seam, adds surface for no behavioral gain. Rejected.C — Route delivery through
cryptosvcwithX-Cairn-User = recipient. Not a separate option — it's what A already does transparently when crypto is split. No action.D — True client-side E2E (sender encrypts to the recipient's published public key). The zero-trust direction (age-to-published-recipient / MLS / Signal-style) where the receiving server never sees plaintext. This is a product-model change, not a delivery fix — Cairn is deliberately server-side transparent today (the code says so: "NOT client-side E2E … not protection against a compromised server"). Cross-instance E2E would need published recipient keys, a sender-side encryption pipeline, and forfeiting server-side features on delivered files. Worth a separate long-term product issue; out of scope for making #106 work under the stated model.
E — Pre-resolve recipient recipients at PrepareTransfer, thread to CompleteTransfer. No benefit over A, adds transfer state, receiver already has the resolver. Rejected.
Recommendation
A, plus two refinements: (1) a distinct failure status when recipient key resolution/provisioning fails (vs generic
STATUS_REFUSED), so the sender can tell "unprovisionable recipient" from "delivery error"; (2) confirm the stack's audit doesn't double-log — the deliverer'speer-deliverstays canonical.Industry framing: this is the standard KMS/server-side-encryption model (resolve any principal's key by identity, à la per-object DEKs) — the KeyProvider is the KMS seam and A stays squarely inside it. D is where zero-trust systems (MLS, Proton/Tresorit) sit; that's a deliberate future product decision, not this bug. Filing D separately.
Verified live on v0.6.23 (2026-08-15)
Option A is deployed and the transfer that failed here now completes end to end.
Andrej-karpathy-skills.md(5.4 KB), files-bao →nikola-test@files, status done at 13:32 — sitting directly above the two 2026-08-14 failures that readpeering: STATUS_REFUSED: peering: write: encrypt: no authenticated user in context.cairn-adminsgroup (no explicit user on the receivers list — group membership resolved live), confirming the fix works with group-based receivers./home/nikola-test/Inbox/bao/on the files instance and renders in their listing — i.e. it was re-encrypted to nikola-test's own key (keycloak-profile custody, resolved by username via the admin client) and is decryptable by them.Every layer of the fix is exercised: recipient context populated → encrypt decorator resolved the recipient's key by username → re-encrypted to recipient + recovery → delivered to their Inbox. Closing confirmed.