Encrypted-content share links #19

Closed
opened 2026-07-25 16:25:43 +00:00 by Cordy · 3 comments
Owner

Public share links (/s/<token>) currently assume plaintext-readable content. When the backing object is age-encrypted, the share handler must decrypt on serve (server-side, using the owner's or a share-scoped key).

  • Decide key scope for shares of encrypted files (owner identity vs dedicated share recipient).
  • Preserve Range + password/expiry semantics.

Deferred from the v0.3 encryption probe.

Public share links (`/s/<token>`) currently assume plaintext-readable content. When the backing object is age-encrypted, the share handler must decrypt on serve (server-side, using the owner's or a share-scoped key). - Decide key scope for shares of encrypted files (owner identity vs dedicated share recipient). - Preserve Range + password/expiry semantics. Deferred from the v0.3 encryption probe.
Author
Owner

Decision — deferred (Manuel's review, relayed by Nikola 2026-07-31):

"Not yet set/decided on how we want to share files."

Stays open but re-gated: the blocker is no longer the encryption review, it's a product decision on the sharing model itself. Until how-we-share is settled, encrypted-content share links have no target to build against. Current shipped behaviour stands documented (main.go note): with per-user custody there is no user context on public /s/, so shares of encrypted content cannot decrypt.

**Decision — deferred (Manuel's review, relayed by Nikola 2026-07-31):** > "Not yet set/decided on how we want to share files." Stays open but re-gated: the blocker is no longer the encryption review, it's a **product decision on the sharing model itself**. Until how-we-share is settled, encrypted-content share links have no target to build against. Current shipped behaviour stands documented (main.go note): with per-user custody there is no user context on public `/s/`, so shares of encrypted content cannot decrypt.
Author
Owner

Design decided (Nikola, 2026-08-04) — this unblocks #19. Sharing splits into two lanes: peering (recipient has a Cairn instance — v0.5 track, delivery re-encrypts to the recipient's own key, #104) and links (recipient has NO instance — this issue). What follows is the links design.

The claim (marketing-honest, agreed): the public share surface stores and serves only ciphertext; compromising the storage backend, the share database, or sniffing the download yields nothing without the key material in your hand. Explicitly NOT "zero-knowledge": a compromised cairnd sees content (it always does — transparent server-side decryption is the product), and the decryption JS is served by the instance itself. We say so in the docs.

Mechanism — on-the-fly age re-encryption (no stored copy): at download, the server decrypts the object internally (owner/space key, exactly like any read) and re-encrypts the stream to a share-specific age recipient. Nothing extra is stored, revocation stays instant (the mint happens per fetch), and the crypto cost rides the existing scaling answer — the --target=crypto role (#33), per Manuel's compute-scaling design. Concurrency-capped like everything else.

Two modes:

  • Fragment mode: an ephemeral age X25519 identity is generated at share creation; the private half goes into the URL #fragment (never sent to the server, server keeps only the public recipient). One self-sufficient artifact — convenience default.
  • Password mode: the payload is encrypted to an age scrypt (passphrase) recipient. Two artifacts (link + password via any out-of-band channel). Note: age does not allow mixing scrypt with other recipient types in one file, so the modes are alternatives per link, not additive.
  • Server-side gate stays in password mode: we keep a download gate with a stored verifier derived separately from the content key (verifier and key from independent salts, so the verifier reveals nothing). Without it, anyone holding the bare link could pull ciphertext and brute-force the passphrase offline; with it, fetch itself requires the password and rate limiting still bites.

Recipient experience: link opens a lightweight viewer page served by the instance; decryption happens in-browser via typage (Filippo's official TypeScript age — the one new UI dependency). v1 caps link-share file size (browser-memory decrypt); streaming decrypt for very large files is a later lift.

This REPLACES the existing plain /s/ share links — not a parallel feature. The new pipeline works uniformly on encrypted AND unencrypted instances (on unencrypted ones the "internal decrypt" is a plain read; the share surface still serves only ciphertext). Everything the current shares have carries over: expiry, revocation, admin list/revoke-all, and the org policy panel applies unchanged and instance-wideshareMaxExpiry (enforced ceiling), shareRequirePassword (forces password mode, fragment mode refused), shareUploadsForbidden. Upload-drop links survive too and get stronger on encrypted instances: anonymous uploads are encrypted to the owner's public recipient + recovery — no secrets involved, the public surface never holds plaintext at rest.

Known and accepted: a recipient who has downloaded ciphertext and holds the key keeps that file forever — revocation stops future fetches, nothing un-rings the bell (true of every sharing system; documented). Wrong password in password mode fails at the gate (server) — the in-browser decrypt failure path exists only if someone bypasses the viewer.

age-CLI compatibility (payload is a genuine age file, so curl | age -d would work): deliberately NOT surfaced or documented as a feature — a very small user subset would care. Filed for market review in v0.6 (#113).

Build split:

  1. Server: share-mint pipeline (ephemeral/scrypt recipients, on-the-fly re-encrypt through the crypto role, password verifier gate, policy enforcement, replace the /s/ payload path).
  2. Web: share-dialog changes (mode selection under policy) + recipient viewer page with typage.
  3. Docs: handbook page + the claim-boundary text.
**Design decided (Nikola, 2026-08-04) — this unblocks #19.** Sharing splits into two lanes: **peering** (recipient has a Cairn instance — v0.5 track, delivery re-encrypts to the recipient's own key, #104) and **links** (recipient has NO instance — this issue). What follows is the links design. ## Strong shares: ciphertext-only share links **The claim (marketing-honest, agreed):** *the public share surface stores and serves only ciphertext; compromising the storage backend, the share database, or sniffing the download yields nothing without the key material in your hand.* Explicitly NOT "zero-knowledge": a compromised cairnd sees content (it always does — transparent server-side decryption is the product), and the decryption JS is served by the instance itself. We say so in the docs. **Mechanism — on-the-fly age re-encryption (no stored copy):** at download, the server decrypts the object internally (owner/space key, exactly like any read) and re-encrypts the stream to a **share-specific age recipient**. Nothing extra is stored, revocation stays instant (the mint happens per fetch), and the crypto cost rides the existing scaling answer — the `--target=crypto` role (#33), per Manuel's compute-scaling design. Concurrency-capped like everything else. **Two modes:** - **Fragment mode:** an ephemeral age X25519 identity is generated at share creation; the private half goes into the URL `#fragment` (never sent to the server, server keeps only the public recipient). One self-sufficient artifact — convenience default. - **Password mode:** the payload is encrypted to an age **scrypt (passphrase) recipient**. Two artifacts (link + password via any out-of-band channel). Note: age does not allow mixing scrypt with other recipient types in one file, so the modes are alternatives per link, not additive. - **Server-side gate stays in password mode:** we keep a download gate with a stored *verifier* derived separately from the content key (verifier and key from independent salts, so the verifier reveals nothing). Without it, anyone holding the bare link could pull ciphertext and brute-force the passphrase offline; with it, fetch itself requires the password and rate limiting still bites. **Recipient experience:** link opens a lightweight viewer page served by the instance; decryption happens in-browser via `typage` (Filippo's official TypeScript age — the one new UI dependency). v1 caps link-share file size (browser-memory decrypt); streaming decrypt for very large files is a later lift. **This REPLACES the existing plain `/s/` share links** — not a parallel feature. The new pipeline works uniformly on encrypted AND unencrypted instances (on unencrypted ones the "internal decrypt" is a plain read; the share surface still serves only ciphertext). Everything the current shares have carries over: expiry, revocation, admin list/revoke-all, and the **org policy panel applies unchanged and instance-wide** — `shareMaxExpiry` (enforced ceiling), `shareRequirePassword` (forces password mode, fragment mode refused), `shareUploadsForbidden`. Upload-drop links survive too and get *stronger* on encrypted instances: anonymous uploads are encrypted to the owner's public recipient + recovery — no secrets involved, the public surface never holds plaintext at rest. **Known and accepted:** a recipient who has downloaded ciphertext and holds the key keeps that file forever — revocation stops future fetches, nothing un-rings the bell (true of every sharing system; documented). Wrong password in password mode fails at the gate (server) — the in-browser decrypt failure path exists only if someone bypasses the viewer. **age-CLI compatibility** (payload is a genuine age file, so `curl | age -d` would work): deliberately NOT surfaced or documented as a feature — a very small user subset would care. Filed for market review in v0.6 (#113). **Build split:** 1. Server: share-mint pipeline (ephemeral/scrypt recipients, on-the-fly re-encrypt through the crypto role, password verifier gate, policy enforcement, replace the `/s/` payload path). 2. Web: share-dialog changes (mode selection under policy) + recipient viewer page with typage. 3. Docs: handbook page + the claim-boundary text.
Author
Owner

Shipped in v0.3.30 (PRs #114 phase 1, #115 phase 2, #116 phase 3) — live on .249.

The links lane is complete: every share download is decrypted internally and re-encrypted on the fly to that link's own age recipient, so the public /s/ surface serves ciphertext only; the recipient opens the link and gets their file, decrypted in-browser, no account or tooling. Fragment mode (key after the #, never transmitted) and password mode (age scrypt recipient; the password also unlocks the gate so ciphertext can't be harvested for offline cracking) are both selectable in the share dialog, with the org policy forcing password mode when configured. Legacy plain links keep working until they expire. Upload links now encrypt anonymous uploads to the owner's key.

Docs: docs/handbook/sharing.md carries the claim boundary verbatim — ciphertext-only surface, explicitly not zero-knowledge, and revocation's limit. The README's "four third-party dependencies" line was corrected to name the one vendored browser asset (pinned typage build, embedded, never CDN).

Dogfood checklist for .249 (share links are enabled there):

  • Fragment link: create, open in a private window → file downloads and opens; strip the #… part → viewer refuses with the "missing its key" message.
  • Password link: create, open → prompt; wrong password rejected at the gate; right password → file downloads.
  • Ciphertext proof: curl -sL '<link>?payload=1' | head -c 60age-encryption.org/v1, and the plaintext is nowhere in the response.
  • Policy: turn on "Require a password on share links" in Settings → the dialog forces password mode and a raw API call with mode:"fragment" is refused.
  • Revocation: delete the link → both the viewer and ?payload=1 404.
  • Upload link into a directory → the uploaded object in Garage is an age file.

Remaining threads tracked elsewhere: peering lane in v0.5 (#100–#106), CLI-compat marketing review in v0.6 (#113), streaming decrypt for very large files (noted in the handbook, no issue yet — file one if the beta hits it).

Closing #19; reopen or file follow-ups from the dogfood.

**Shipped in v0.3.30** (PRs #114 phase 1, #115 phase 2, #116 phase 3) — live on .249. The links lane is complete: every share download is decrypted internally and re-encrypted on the fly to that link's own age recipient, so the public `/s/` surface serves ciphertext only; the recipient opens the link and gets their file, decrypted in-browser, no account or tooling. Fragment mode (key after the `#`, never transmitted) and password mode (age scrypt recipient; the password also unlocks the gate so ciphertext can't be harvested for offline cracking) are both selectable in the share dialog, with the org policy forcing password mode when configured. Legacy plain links keep working until they expire. Upload links now encrypt anonymous uploads to the owner's key. Docs: `docs/handbook/sharing.md` carries the claim boundary verbatim — ciphertext-only surface, explicitly **not** zero-knowledge, and revocation's limit. The README's "four third-party dependencies" line was corrected to name the one vendored browser asset (pinned typage build, embedded, never CDN). **Dogfood checklist for .249** (share links are enabled there): - [ ] Fragment link: create, open in a private window → file downloads and opens; strip the `#…` part → viewer refuses with the "missing its key" message. - [ ] Password link: create, open → prompt; wrong password rejected at the gate; right password → file downloads. - [ ] Ciphertext proof: `curl -sL '<link>?payload=1' | head -c 60` → `age-encryption.org/v1`, and the plaintext is nowhere in the response. - [ ] Policy: turn on "Require a password on share links" in Settings → the dialog forces password mode and a raw API call with `mode:"fragment"` is refused. - [ ] Revocation: delete the link → both the viewer and `?payload=1` 404. - [ ] Upload link into a directory → the uploaded object in Garage is an age file. Remaining threads tracked elsewhere: peering lane in v0.5 (#100–#106), CLI-compat marketing review in v0.6 (#113), streaming decrypt for very large files (noted in the handbook, no issue yet — file one if the beta hits it). Closing #19; reopen or file follow-ups from the dogfood.
Cordy closed this issue 2026-08-04 21:32:36 +00:00
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#19
No description provided.