Share Links
DraftShare links let a Capsule user grant view (and possibly limited write) access to an album or a specific asset without requiring the recipient to have a Capsule account. The recipient is the non-registered account class — no master key, no User IK, no MLS membership. The cryptographic shape (the link secret carries the decryption material; an optional passphrase wraps it with the password-based KDF) is owned by Cryptography — Keys: Non-registered accounts; this doc owns everything else.
Implementation will live in the planned capsule-server::share module (public-share serving endpoints) and capsule-core::sharing (link generation, capability validation).
Scope (v1)
Section titled “Scope (v1)”In scope:
- View-only links to a single asset.
- View-only links to a whole album.
- Optional passphrase protection (the link secret + a user-chosen passphrase, both required to decrypt).
- Optional expiry (link valid until a specific timestamp).
- Revocation (publish a revocation record; the serving endpoint refuses revoked links).
Out of scope for v1 (deliberate non-goals):
- Writable share links. Writing requires a write-tier key + a place in the MLS group; a non-registered user has neither. Supporting writes would require an ephemeral link-scoped key hierarchy — a substantial new design that is not justified for v1. Guest contribution (a non-registered user depositing assets) is instead provided by Web Upload’s upload links — a distinct write-to-inbox capability whose product is a staged drop, which does not make these view links writable.
- Per-recipient analytics. Link views are not tracked per-recipient. The link is the credential; the server knows it was used, not by whom.
Security Contract
Section titled “Security Contract”These are normative — the security-relevant decisions are committed; only UX presentation remains open.
- URL format.
https://server.tld/s/{opaque_id}#{secret}— the secret lives in the URL fragment, which browsers never transmit, so the server holds only{opaque_id}and never the decryption secret.{opaque_id}is fully opaque and carries no scope; the asset/album scope is resolved server-side from the link record, so the URL itself leaks nothing about what it points to. - Opaque-id entropy.
{opaque_id}is a random 128-bit value drawn from the CSPRNG — a full 128 bits of entropy, not a UUIDv7 or other structured id whose embedded timestamp would cut real entropy to ~62 bits. No shorter or sequential identifier is permitted — this is the structural defense against link enumeration, independent of rate limiting. - Serving-endpoint rate limits. The public serve path is rate-limited per source IP and per
{opaque_id}(two independent limiters) and returns an indistinguishable404— never410 Gone, which would confirm a link once existed — for a not-found, revoked, or expired link alike, so probing reveals nothing and fast enumeration is throttled. - Passphrase unwrap is client-side. When a passphrase protects a link, the server stores only the wrapped secret and never receives the passphrase: the client fetches the wrapped material and unwraps it locally via the password-based KDF. The server is never in the password-trust path, so a server compromise cannot brute-force passphrases beyond the Argon2id cost already imposed. Because unwrap is client-side the server cannot observe a failed attempt, so the endpoint that returns the wrapped material is rate-limited per source IP and per
{opaque_id}(the same limiter as the serve path); the Argon2id cost is the real brute-force backstop. - The privacy strip is mandatory, and it happens at issue. A public share is by definition a boundary crossing, so the Privacy on Export strip (camera serial, device/session ids, GPS truncated to city level, contact tags) is applied by the issuing client, which seals the stripped metadata into the blob the link points at. There is no per-share opt-out that could leak fingerprinting fields. The serve path cannot be the enforcement point and it is important to say why rather than to leave it implied: a share’s metadata is sealed under material derived from the URL fragment, which by this contract’s own design never reaches the server — a server that could strip a share’s metadata could read every share. The server’s half is containment: a link serves only the content addresses its own record enumerates, so a share cannot be walked sideways into the album’s unstripped metadata, even though the same store holds it. That is what makes the client’s strip stick, and it is checkable server-side where a strip is not.
- Home-server-only serving. A share link is served only by the album’s home server. A federated peer never serves a share; a share-scoped request at a peer returns a structured
{ home_server }JSON pointer the client resolves — explicitly not an HTTP redirect, to avoid an open-redirect surface — never content. This keeps revocation and rate-limiting at a single authoritative point. - Revocation cache. Home-server-only serving still leaves intra-server staleness: the serve path may run on several processes or replicas of the one home server, which consult revocation state through a short-TTL cache (default 60 s) rather than an authoritative read per request. The posture is fail-closed, matching the federation revocation list: a serving process that cannot confirm a link is still live past the TTL refuses rather than serving on stale-allowed state. (A single-process deployment reads revocation state directly and the cache is a no-op.)
Status note. The serve path, the owner’s issue and revoke operations, and the indistinguishable 404 are served today by the Kynos surface, with slice S-C4. The containment half of the strip rule above is enforced and tested there. Two qualifications remain, recorded rather than implied:
- The two rate limiters are absent, not merely per-process: the counter port they need is
S-C32’s. The429is deliberately not declared in the served contract rather than declared and unreachable. What holds without them is the defense this contract calls independent of rate limiting — the 128-bit opaque id, checked for shape at issue. - The revocation cache is a no-op, exactly as the bullet above allows for a single-process deployment: the serve path resolves authoritatively on every request. The fail-closed rule is what a multi-replica cache must be written to, and the surface already has the shape it needs — a store that cannot answer is a
500, never an implicit “live” and never the404that would read as “revoked”.
Contract Skeleton
Section titled “Contract Skeleton”The surfaces consuming code needs; the security policies they enforce are fixed by the Security Contract above.
// in capsule-core::sharingtrait ShareLinkIssuer { fn create_link(scope: ShareScope, expiry: Option<DateTime>, passphrase: Option<&str>) -> Result<ShareLink, Error>; fn revoke(link_id: ShareLinkId) -> Result<(), Error>;}
// planned in capsule-server::share// GET /s/{opaque_id} → metadata blob + LQIP (mandatory server-side strip — see Security Contract)// GET /s/{opaque_id}/blob/{hash} → ciphertext blob; client decrypts using link-derived key// GET /s/{opaque_id}/wrapped-secret → the passphrase-WRAPPED link material, when the link is// passphrase-protected; unwrap is client-side via the password-based// KDF. The passphrase itself is NEVER transmitted (Security Contract).Concrete error variants are an implementation detail; the rate-limit, opaque-id entropy, privacy-strip, and revocation policies are fixed by the Security Contract above.
Failure Modes
Section titled “Failure Modes”- Link enumeration. Defeated structurally by the ≥128-bit opaque-id and operationally by per-IP/per-link rate limits with indistinguishable
404s (see Security Contract). - Revoked link still served. Home-server-only serving means a single authoritative revocation point — no peer caches a share to serve stale — and the 60 s revocation cache fails closed past its TTL.
- Passphrase brute force. The Argon2id wrap makes weak passphrases survivable; client-side unwrap keeps the server out of the trust path; the rate-limited serve endpoint is the operational backstop.
Validation
Section titled “Validation”- Opaque-id entropy (unit). Assert generated ids are ≥128-bit and non-sequential; a generator producing shorter or guessable ids fails the test.
- Enumeration resistance (smoke). Probe the serve endpoint with random ids; assert per-IP/per-link rate limiting, and that not-found, revoked, and expired all return an indistinguishable
404. - Passphrase unwrap locality (unit). Assert the passphrase never crosses the wire — the server stores and returns only the wrapped secret; unwrap happens client-side.
- Revocation honored (smoke). Revoke a link; assert the serve endpoint refuses within the 60 s cache window, and fails closed past TTL when revocation state is unreachable.
- Privacy-strip on serve (unit). Assert the boundary-crossing field set is always stripped from the served metadata blob, with no opt-out path.
- Home-server-only (unit). Assert a federated peer refuses to serve a share and returns a home-server pointer.
(The validation surface grows with the client UX, but the security checks above are committed.)