Strong shares phase 1: ciphertext-only share payloads (#19) #114

Merged
Cordy merged 6 commits from feat/strong-shares into main 2026-08-04 20:12:14 +00:00
Owner

Phase 1 of the #19 links lane (design agreed 2026-08-04). Server pipeline only — the recipient viewer page is phase 2, so for now a strong share downloads as <name>.age.

What it does: on every download of a strong share, the server reads the object through the normal storage stack (decrypting with the owner's/space's key like any read) and re-encrypts the stream on the fly to a share-specific age recipient. No stored second copy, revocation stays instant, memory stays flat (streaming — the #5 lesson), and the crypto cost rides the --target=crypto role (#33) like all other encryption work.

Modes (alternatives, because age refuses to mix scrypt with other recipient types):

  • fragment (default for new links) — an ephemeral X25519 identity is minted at creation; only the public recipient is persisted, the identity is returned once in the create response and appended to the URL as #<key>. Browsers never send fragments, so the server cannot see it again.
  • password — payload encrypted to an age scrypt recipient. The passphrase both unlocks the existing server-side gate and is the key material: the gate session now carries it in memory for the session TTL. The stored argon2id hash remains a verifier derived independently of the content key, so ciphertext can't be harvested for offline brute-forcing.
  • plain — the legacy plaintext payload, still selectable; existing pre-#19 links (empty mode) keep working untouched.

Policy (#62) is unchanged and still enforced at the same chokepoint: shareRequirePassword (a password implies password mode), shareMaxExpiry, shareUploadsForbidden. Password mode without a password is refused.

Details worth reviewing: strong payloads set Accept-Ranges: none + no-store (the ciphertext is minted per request, so client-side byte offsets are meaningless); HEAD returns headers only; a mid-stream encryption failure truncates the payload, which is self-signalling since an incomplete age file never decrypts.

Tests: fragment roundtrip (payload is a real age file, plaintext absent, only the fragment identity opens it), password roundtrip + wrong-passphrase rejection, missing-key-material refusals for all three modes, fragment recipient uniqueness. Full suite green.

Next: phase 2 (share dialog mode selection + typage viewer page), phase 3 (handbook page with the claim boundary).

Phase 1 of the #19 links lane (design agreed 2026-08-04). Server pipeline only — the recipient viewer page is phase 2, so for now a strong share downloads as `<name>.age`. **What it does:** on every download of a strong share, the server reads the object through the normal storage stack (decrypting with the owner's/space's key like any read) and re-encrypts the stream on the fly to a share-specific age recipient. No stored second copy, revocation stays instant, memory stays flat (streaming — the #5 lesson), and the crypto cost rides the `--target=crypto` role (#33) like all other encryption work. **Modes** (alternatives, because age refuses to mix scrypt with other recipient types): - `fragment` (default for new links) — an ephemeral X25519 identity is minted at creation; only the public recipient is persisted, the identity is returned **once** in the create response and appended to the URL as `#<key>`. Browsers never send fragments, so the server cannot see it again. - `password` — payload encrypted to an age scrypt recipient. The passphrase both unlocks the existing server-side gate and *is* the key material: the gate session now carries it in memory for the session TTL. The stored argon2id hash remains a verifier derived independently of the content key, so ciphertext can't be harvested for offline brute-forcing. - `plain` — the legacy plaintext payload, still selectable; existing pre-#19 links (empty mode) keep working untouched. **Policy** (#62) is unchanged and still enforced at the same chokepoint: `shareRequirePassword` (a password implies password mode), `shareMaxExpiry`, `shareUploadsForbidden`. Password mode without a password is refused. **Details worth reviewing:** strong payloads set `Accept-Ranges: none` + `no-store` (the ciphertext is minted per request, so client-side byte offsets are meaningless); HEAD returns headers only; a mid-stream encryption failure truncates the payload, which is self-signalling since an incomplete age file never decrypts. **Tests:** fragment roundtrip (payload is a real age file, plaintext absent, only the fragment identity opens it), password roundtrip + wrong-passphrase rejection, missing-key-material refusals for all three modes, fragment recipient uniqueness. Full suite green. **Next:** phase 2 (share dialog mode selection + typage viewer page), phase 3 (handbook page with the claim boundary).
Cordy merged commit d6012bcc8f into main 2026-08-04 20:12:14 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
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#114
No description provided.