Shellcaster Security Whitepaper

End-to-end encrypted session sharing: design, algorithms, and threat analysis
Version 1.0 · July 2026 Status: Beta — implementation-accurate as of this date Audience: security reviewers, enterprise evaluators, researchers

§Abstract

Shellcaster is a macOS development cockpit that lets a developer share live views of their work — local files, running sites, and the annotation threads on top of them — with invited collaborators. Private shared sessions are end-to-end encrypted: all annotation content, screenshots, and shared-file contents — including every subsequent version as a shared file changes on disk (§7.2) — are encrypted on the author's device under a per-session symmetric key that is delivered exclusively via per-device asymmetric lockboxes. The server stores, orders, and relays ciphertext it cannot read; the storage layer (S3) holds ciphertext under presigned URLs; the realtime layer (Ably) carries only content-free signals.

This paper specifies the identity model, the key hierarchy, every cryptographic primitive in use with its exact parameters, the admission and revocation semantics, and an honest accounting of what metadata the operator can observe. It closes with the threat analysis, current limitations, and instructions for reproducing our cross-implementation verification suite. §8.4 describes the identity-verification layer — cross-signing, key transparency, an out-of-band verification code, proof-of-possession, and the recovery / re-approval flow — that binds a person's real keys to their identity and defends against first-contact key substitution; its server side ships today (implementation status noted inline), with client-side proof checking staged behind it (§13).

The core claim. For a private session, no party outside the session roster — including Shellcaster's operators, its database, its object storage, and its realtime provider — can read shared content. Confidentiality rests on keys that exist only on participants' devices.

1System Overview

A session is scoped to one project and is either private or public; the visibility is fixed for the session's lifetime. A private session has a roster: the host plus the people they invited, identified by identity-provider-attested email addresses. Session content is an append-only stream of typed events (annotation threads, comments, reactions, file snapshots) plus blobs (screenshots, shared-file contents) referenced by events.

Independently of visibility, a session is either backed or unbacked — its durability. A backed session is a lasting thread: its encrypted events persist after the live moment ends, so replies keep flowing while participants are offline, late joiners can catch up, and the event record remains until the host deletes it — though its blobs (file and screenshot content) expire at 30 days. An unbacked session exists for the live moment only: when it ends, its stored content is queued for deletion on a 24-hour clock, and no unbacked session lives longer than 30 days regardless (§7.2). Durability changes what the server retains, never what it can read — a backed private session is ciphertext for as long as it lives. The Mac client currently creates backed sessions.

  • Hosts start sessions, share views (files or URLs), and invite collaborators.
  • Guests join by signing in as an invited email; their device receives the session key via a lockbox and decrypts the stream locally.
  • The server authenticates identities, enforces roster membership, orders the event stream, meters billing, and brokers storage/realtime access. It holds no decryption capability for private content.

Five external systems participate: Google and GitHub as identity providers, PostgreSQL as the system of record, Amazon S3 for blob storage, and Ably for realtime signals. Sections 3–9 define exactly what each is trusted with.

2Threat Model

2.1Adversaries in scope

AdversaryCapability assumedGoal defended against
Curious / compelled operatorFull read access to the API server, database, S3 buckets, Ably account, and all traffic between themReading private session content
Database exfiltrationA complete dump of PostgreSQLRecovering content, session keys, or confirming known documents
Storage compromiseRead access to all S3 objectsReading screenshots or shared files
Network observerPassive observation of client↔server traffic (assume TLS may terminate at intermediaries such as tunnels or load balancers)Reading content in transit
Authenticated outsiderA valid Shellcaster account that is not on a session's roster; possession of a session's join linkJoining, reading events/blobs, probing for content
Removed participantA former roster member who retains the old session keyReading anything shared after removal
Invite-link leakA forwarded, logged, or intercepted join linkAny access whatsoever (links are non-secret by design; see §8.1)

2.2Explicitly out of scope

  • A compromised participant endpoint. Malware with the user's privileges on a roster member's Mac can read what that user can read. E2E encryption defines who holds keys, not the integrity of their machines.
  • A malicious roster member. Anyone legitimately holding the session key can exfiltrate plaintext. Sharing is an act of trust in the people you name; the system bounds the blast radius (per-session keys, rekey-on-removal) but cannot prevent authorized recipients from leaking.
  • A malicious client binary. Users trust the Shellcaster application they run, as with every E2E messenger. Reproducible builds are future work (§13).
  • Traffic-analysis metadata. Timing, sizes, and frequency of encrypted events are visible to the operator; §11 enumerates this honestly.
  • Denial of service by the operator. A server that refuses to relay ciphertext degrades availability, never confidentiality.

3Architecture & Trust Boundaries

HOST DEVICE (macOS)
X25519 device key · Keychain
K_session · memory + sealed file
encrypts before anything leaves
trusted with keys
OPERATOR INFRASTRUCTURE
API + PostgreSQLidentity · roster · ULID ordering · billing
Amazon S3presigned, single-object URLs
Ablyscoped channel tokens
metadata + ciphertext — never keys
GUEST DEVICE (macOS)
X25519 device key · Keychain
K_session ← opened lockbox
decrypts after everything arrives
trusted with keys
Host events · {v, n, ct} AEAD API · PostgreSQL replay → decrypt Guest
Host blobs · nonce ‖ ciphertext S3 presigned GET → decrypt Guest
Host sealed lockbox per device API · PostgreSQL open with device key Guest
Host signals — ids only, never content Ably signals — ids only, never content Guest
ciphertext the operator cannot open content-free metadata & signals dashed boundary = no keys inside, ever

The trust boundary is the device edge. Every arrow crossing it carries either ciphertext, a sealed box, or metadata enumerated in §11. Three server-side properties reinforce the boundary:

  • Opaque identifiers. All public identifiers (sessions, invites, exhibits, participants, users) are non-sequential, high-entropy serials rather than the underlying database ids — resources cannot be enumerated by counting or guessed from a neighbor.
  • Roster-gated surface. Every session endpoint resolves the caller's roster relationship first; unauthenticated calls receive 401, authenticated non-members a flat 403. There is no anonymous pathway anywhere in the session surface (the anonymous tier was removed by design decision in July 2026).
  • Storage brokering. Clients never hold S3 credentials; the server issues short-lived presigned URLs scoped to a single object under the session's prefix, and only after the roster check.

4Identity & Authentication

4.1The identity model: an account is an IdP-attested email

Shellcaster does not operate passwords. An account is an email address whose ownership was attested by an identity provider: Google (all emails are verified by construction) or GitHub (the callback accepts only addresses GitHub marks verified: true). This is load-bearing: roster membership — the thing that admits a person to a private session — is checked against this attested email. Guard comments at both code paths where attested identity enters the system prohibit any future self-asserted-email login (magic links) from reaching the roster check, which would otherwise collapse the model.

4.2Desktop sign-in: loopback redirect + PKCE

The Mac app signs in with the OAuth 2.0 flow recommended for native apps (RFC 8252 §7.3):

  1. The app binds a transient HTTP listener to 127.0.0.1 on an ephemeral port (loopback only — never an external interface).
  2. It opens the system browser at Google's authorization endpoint with a PKCE challenge (RFC 7636, method S256: BASE64URL(SHA-256(verifier)) with a 32-byte random verifier) and a 16-byte random state.
  3. Google redirects to the loopback listener; the app validates state and captures the one-time authorization code.
  4. The code and verifier go to the Shellcaster server which performs the code exchange with Google. The OAuth client secret lives only on the server — it never ships in the desktop binary. The server validates the returned ID token's signature, expiry, audience, and email_verified claim before creating or resolving the account.

The PKCE verifier binds the code to the app instance that started the flow; the loopback redirect means no custom-scheme hijacking; the state check prevents CSRF-style code injection into the listener.

4.3API sessions

A successful exchange returns a JWT (HMAC-SHA256 signed, 30-day expiry, issuer- and audience-pinned) that the app stores in the macOS Keychain and presents as an Authorization: Bearer header. Two server-side properties matter beyond the signature:

  • The session row is the authority, not the token. Every authenticated request re-checks a server-side session row keyed by the token's user and login-device id. Logout, admin disable, account deletion, and a 30-day idle timeout all delete that row — a stolen token dies with it, regardless of its remaining JWT lifetime.
  • Auth tokens are not crypto keys. A JWT admits its holder to the API as an identity; it conveys no ability to decrypt anything. Content confidentiality never rests on bearer-token secrecy.

4.4Web sign-in: state binding and redirect safety

The browser sign-in flow (Google and GitHub, used by the web app) adds two guards on top of the provider exchange:

  • State is bound to the browser. Each authorization request carries a single-use random nonce in its state, mirrored into an HttpOnly, SameSite=Lax cookie. The callback completes only if the returned state's nonce matches the cookie, and then clears it — so a forged or replayed callback (login-CSRF) is refused rather than silently logging a victim's browser into an attacker's account.
  • Redirects are same-origin only. The post-login redirect is user-supplied, so the callback validates it before use: an absolute URL must resolve to the application's own host or it is dropped to the home path, and protocol-relative (//host) targets are refused outright. The parameter can never become an open redirect to an external site.

As defense in depth, the token the provider returns is checked for the expected audience and issuer before any account is created or resolved.

5Device Identity & Key Storage

Each installation generates one X25519 device keypair (libsodium crypto_box_keypair) on first use. The public half is registered with the server's device directory, bound to the signed-in account; the private half never leaves the device.

Keychain policy — exactly one secret, held natively. The device private key is the single long-lived cryptographic secret on the machine, stored as one item ("Shellcaster Device Key") in the macOS data protection keychain (kSecUseDataProtectionKeychain, accessibility kSecAttrAccessibleAfterFirstUnlock), access-controlled by the app's code-signing entitlement rather than legacy keychain ACL dialogs. Session keys are deliberately not Keychain items (see §6.3), so the Keychain inventory stays auditable: a user opening Keychain Access sees precisely two Shellcaster items — the device key and the API token — no matter how many sessions come and go.

Fingerprints. Devices are displayed to humans via a fingerprint: the first 8 hex characters of SHA-256(device public key), formatted ABCD-1234. Fingerprints appear in admission prompts and join notifications (§8.2) so participants can verify which physical machine was granted a key.

Revocation. A device's owner can soft-revoke it — an authenticated, owner-only operation: the directory stops answering for its public key and the server refuses to store new lockboxes addressed to it. Lockboxes delivered before revocation die at the session's next rekey (§8.3). Revocation is strictly owner-scoped: the mutation matches the device id and the calling account together, so a caller who knows another user's device id — as roster members legitimately do, since admission signals carry them — receives a 404, never a revocation. Device ids are identifiers, not credentials; no device-surface operation treats possession of one as authority. Likewise, a device row can never be re-homed to a different account — the server rejects registration collisions rather than re-addressing existing lockboxes.

Trust-on-first-use pinning. Clients pin the device public keys they first observe for each recipient. If the directory later returns a different key for a pinned device — the signature of a compromised directory or a "ghost device" insertion — the client refuses to wrap the session key for it and raises a visible warning. A changed key is never silently re-trusted.

6Session Keys & Lockboxes

6.1K_session lifecycle

When a host starts a private session, their device mints K_session: 32 bytes from libsodium's CSPRNG (randombytes_buf). This key encrypts every event payload and blob for the session's lifetime. It is never transmitted, stored, or logged in any form the server can open. Its life ends at session stop or rekey, at which point a successor session mints a fresh key — key rotation is structural (§8.3), not optional hygiene.

6.2Lockboxes: per-device key delivery

When a host shares a private session with someone, the host's device delivers K_session to the invited person's device — the machine the recipient will actually read on, not any device of the host's — as a lockbox: a libsodium sealed box (crypto_box_seal) addressed to that recipient device's X25519 public key, one lockbox per device the recipient has registered. The construction generates an ephemeral X25519 keypair per seal, performs X25519 ECDH against the recipient device's public key, derives the nonce as BLAKE2b(ephemeral pub ‖ recipient pub), and encrypts with XSalsa20-Poly1305. Only the private key resident in that recipient device's Keychain (§5) can open it. The sealing host discards the ephemeral secret, so once a lockbox is sealed only its addressed device can open it — not the host that made it, and not the server that stores it. (The host's own device receives one lockbox as well, purely as its recovery copy — see §6.3.) A 32-byte K_session seals to exactly 80 bytes; the server validates size bounds (48–512 bytes) and stores the blob opaquely, one row per (session, device), upsert semantics.

Wrap-ahead. Inviting an existing user returns their registered device public keys inline (an intent-scoped directory disclosure, recorded in an audit journal), so the host seals lockboxes at invite time. The invitee's key is waiting before they ever click — the host can go offline immediately.

People search is not an existence oracle. The Add Collaborator picker's typeahead resolves a name only among people the caller has previously invited (scoped server-side to the caller's own invite rows) — never the global user table — so it can only tell you an email you already knew. Typing an arbitrary email discloses nothing either: the response never says whether that address holds an account; the client simply offers "invite by email", and identity attaches after acceptance. Every people-search query requires a signed-in account and is journaled before it executes; per-account rate limits are computed from that journal, making bulk enumeration both expensive and visible.

The waiting room. If a device has no lockbox yet (brand-new user, brand-new device), fetching returns HTTP 202 "waiting for the host to let you in". The joiner is authorized-but-keyless until a live keyholder seals for them; the server relays a classified admission signal (§8.2) to prompt one. This is deliberate strictness by default: no key material is ever escrowed server-side to smooth the wait.

Any keyholder can admit. Lockbox minting is not host-exclusive: any roster member holding K_session may seal for a newly admitted device (e.g., a guest's second Mac vouched by their first). The server enforces that lockboxes can only be fetched by the device's owner.

6.3Restart persistence: the sealed key file

To survive an app relaunch without re-running key delivery, each participant persists K_session to disk as a sealed file: crypto_box_seal(K_session, own_device_public_key), stored beside the project's local state. On launch it is opened with the Keychain device key. The file is deleted on session stop and rekey; an orphaned copy is ciphertext openable only by that device's Keychain. This keeps the at-rest story uniform: the only plaintext secret on disk anywhere is inside the macOS keychain.

7Content Encryption

7.1Events

Session content is an append-only event stream. On private sessions, each event's payload is encrypted on-device with XChaCha20-Poly1305 (IETF) under K_session — a 24-byte random nonce per event (the extended nonce makes random generation collision-safe at any realistic volume) and a 16-byte Poly1305 tag. The wire format:

{ "v": 1, "n": "<24-byte nonce, base64>", "ct": "<ciphertext ‖ tag, base64>" }

The server stores this JSON opaquely, assigns a ULID (lexically time-ordered, giving cheap ?after= catch-up cursors), and enforces two envelope rules: payloads are capped at 64 KB (content belongs in blobs), and the encrypted flag must match the session's visibility — a plaintext payload on a private session is rejected at write time, so a buggy or malicious client cannot quietly downgrade a session.

Event ids are session-scoped in effect: every read and cursor is keyed by (session, id) behind the roster gate, so an id carries no meaning and confers no access outside its own session's stream — knowing another session's event ids gets an outsider exactly nothing. And because a ULID is a timestamp plus 80 bits of randomness rather than a draw from any shared counter, ids leak no cross-session volume or ordering; the embedded timestamp reveals only the event's own creation time, which the envelope already carries openly (§11).

Envelope fields — event type, author participant, timestamp, blob references — stay plaintext by design, so the server can order the stream, run blob lifecycle, and let catch-up clients prefetch, all without decrypting. §11 accounts for this metadata.

7.2Blobs & file snapshots

Bulk content — annotation screenshots and the contents of shared files — travels as blobs. On private sessions a blob is encrypted with the same AEAD under K_session and stored on S3 as nonce (24 B) ‖ ciphertext, uploaded via a server-issued presigned URL after the roster check. Downloads reverse the path: presigned GET, split the nonce, decrypt locally, and verify the content digest against the blob id.

Shared-file snapshots — every change is captured as a fresh encrypted blob. Sharing a file view shares its contents, initially and continuously. The lifecycle of a shared file:

  1. At share time the file's bytes are read and published immediately (steps 3–5 below), so a guest never receives a bare path with nothing behind it.
  2. For the life of the share, a filesystem watcher (kernel vnode events) observes the file and catches every write — a user's editor save and an AI agent's rewrite look identical to it. Bursts are debounced to at most one publish per 2 seconds per file, and atomic saves (write-temp-then-rename) are followed across the inode swap so no change is missed.
  3. Change detection is cryptographic: the client derives the keyed content id HMAC-SHA-256(K_session, plaintext) (§7.3). If it equals the version already published for this session, the write was content-neutral and nothing is sent.
  4. A genuinely new version is encrypted and uploaded as a new blob: the plaintext is sealed under K_session (XChaCha20-Poly1305, fresh 24-byte nonce) and the nonce ‖ ciphertext object lands on S3 via a presigned URL. Versions are immutable — a change never overwrites an earlier version's object, so the session accumulates an encrypted version history rather than a mutable file.
  5. The version is announced by a snapshot event whose encrypted payload links the file to its blob (the blob reference also rides the plaintext envelope for server-side lifecycle and prefetch). The append fans out as a realtime nudge (§9): live participants fetch and decrypt the new version within moments.

Replaying the event log therefore yields every version a shared file has been through, each decryptable only by the roster. For late joiners, a latest-content pointer — a client-maintained plaintext column pairing each shared view with its current blob id — provides the current state in one round trip instead of a log replay; the pointer must reference a blob already registered on the session, so it cannot dangle. Blob retention follows the session's durability (§1): an unbacked session's blobs are lifecycle-expired 24 hours after it ends; a backed session's persist until the host deletes.

7.3Keyed content addressing

Blob ids are content-addressed for deduplication — an unchanged file re-publish is a no-op. On private sessions the id is HMAC-SHA-256(K_session, plaintext), not a bare hash. This closes a confirmation channel: with a bare SHA-256(plaintext) id, any party who can see blob ids (the operator, a database dump) could confirm whether a session contains an exact candidate document by hashing their copy and comparing. Keyed under K_session, ids are indistinguishable from random to anyone off the roster. Deduplication within a session is preserved (same key, same content → same id); a rekey changes every id, which is immaterial because a rekey re-uploads all content under the successor session anyway.

7.4Public sessions

Public sessions have no end-to-end encryption, stated plainly: there is no enumerable audience to encrypt to. Events and blobs are plaintext (encrypted: false, enforced), blob ids are bare SHA-256, and the access gate is sign-in plus knowledge of the link. Public sessions are unlisted unless explicitly curated into the community feed by the operator. Every join — public or private — still requires an authenticated, IdP-attested account, and every join is billed to the host, so there is no anonymous read path anywhere.

Converting between visibilities is impossible in place: a "flip" is session-end plus a fresh session with a fresh key, so ciphertext written under a private session can never be retroactively exposed by a visibility change.

7.5Blinded view identities

A shared view is a file path or a URL — either of which can itself carry a secret (a customer name in a directory, a token in a query string). The server never stores the plaintext. A view's server-side identity is a keyed match hash, HMAC-SHA-256(project label key, canonical path-or-URL) — the same keyed-hash move as blob ids (§7.3), for the same reason: a bare hash of a low-entropy, guessable path would invite the confirmation attack §7.3 closes.

  • Deterministic, so anchoring survives. One distinct view always hashes to the same identity, so the server finds-or-mints exactly one exhibit per view and keeps annotation threads anchored to it across sessions, rekeys, restarts, and devices — with no plaintext to read.
  • The key is host-local. The project label key lives only on the host's devices and never reaches the server. Accepted tradeoff: the same project hosted from two of the host's own Macs mints two exhibits.
  • URLs canonicalize first. The query string and fragment are dropped before hashing, so a token in a query never even reaches the hash input.
  • Two keys, two jobs. The human-readable display name reaches authenticated keyholders inside a label event encrypted under K_session — participants see the real file and URL names once admitted; only the operator is blinded. The server-side match identity is the separate keyed hash. That match key must be a distinct, project-lifetime-stable secret because K_session cannot serve it: a per-session key would change on every rekey (breaking the durable cross-session exhibit), and AEAD is non-deterministic (so the server could not equality-match at all).

The one residual: a guest still waiting for a lockbox sees an opaque view placeholder until they hold the key. A future site-scope tunnel — the one feature that would genuinely need a URL in the clear to proxy — would negotiate its address over an encrypted channel or stay opt-in.

8Admission, Roster & Revocation

8.1Identity admits; links carry nothing

An invitation names a person (email or resolved handle) on the session's roster. The join link is shellcaster://join/<session-serial>a locator, not a credential. It contains no token, expires never, and can be forwarded, logged, or posted publicly without granting anything: joining requires signing in as an email the roster names. Consequences of this design:

  • Clicking twice, or from a second device, costs nothing and needs no re-issue — there is no token to burn.
  • A leaked invite email is harmless; the email is a courtesy notification, and its template says so.
  • An authenticated user who is not on the roster gets a flat 403 from every session endpoint — possession of the link changes nothing.
  • Re-inviting the same person is idempotent (one roster row per person per session).

Billing note: one host share-credit is spent per participant, exactly once, at first join — re-entry is free, and the anonymous tier's removal means every spend has an accountable identity on both ends.

8.2Device admission: the two doors

Roster membership authorizes a person; key delivery is per device. When a keyless device requests entry, the server classifies the situation and relays it to keyholders (it never decides admission itself — it holds no keys to grant):

CaseServer-supplied classificationIntended client handling
First device of a newly invited personfirstDeviceForUser: trueSilent trust-on-first-use wrap; the join announcement carries the device name and key fingerprint with an actionable "Remove"
Additional device, owner has a live keyed deviceselfVoucherPossible: trueThe person's own existing device can vouch (self door) or the host can (host door) — an explicit press either way
Additional device, no self-voucher possibleneither flagLoud host-side confirmation before any wrap

The classification signal carries the device fingerprint, name, and the person's email, and is delivered both to the session channel (host door) and to the device owner's personal channel (self door). Combined with TOFU pinning (§5), this is the defense against silent key insertion: every key enters the session through a deliberate wrap by a keyholder rather than the server, a changed key on an already-pinned device is refused outright, and a device admitted at join time surfaces its identity — fingerprint, name, email — for an explicit press. Wrap-ahead to a recipient's already-registered devices leans on that same TOFU pin; the one case a pin cannot yet cover — a first-contact substitution before any pin exists — is addressed in §8.4.

Admission is explicit for every waiting device. The host's client surfaces each request both as a corner popup (Ably-driven, while hosting) and as a "Waiting to join" section in the share sheet, each offering Admit / Decline / dismiss. Admitting performs the audited directory lookup for the device's public key, verifies it against the fingerprint the signal carried (TOFU), seals K_session to it, and posts the lockbox — the waiting guest unblocks the instant it lands. Declining is roster removal; because a still-waiting device never held the key, that decline is clean and needs no rekey. (The silent first-device auto-wrap the table above allows is the lower-friction option; explicit host control is the default.)

8.3Removal is a rekey — the E2E-honest semantics

E2E encryption makes one rule non-negotiable: anyone who ever held K_session can decrypt everything sent under it. Shellcaster does not pretend otherwise:

  • Server-side, removing a person marks their roster intent removed — an immediate, durable 403 that outranks any lingering participation row on every read path, including the "shared with you" listing. Their lockboxes stop mattering because of what happens next.
  • Client-side, removal triggers a rekey: the session ends, a successor session mints a fresh K_session, lockboxes are re-sealed for the survivors only, and content republishes under the new key. The removed member's key opens a dead stream; nothing new is ever encrypted under it. (In the current UI this flow is deliberately routed through the explicit stop-and-reshare action — the live roster is add-only, and invitations on a live session are staged and sent only on an explicit commit, closing the mis-invite window before anything is transmitted.)
  • Forward secrecy at session granularity. Each session's compromise exposure is bounded by its own key. There is no long-lived content key whose loss unravels history across sessions.

8.4Identity verification: cross-signing, key transparency, and out-of-band confirmation

Change-detection alone is not enough: TOFU pinning catches a key that changes, but not one that was wrong from the very first fetch. That is first-contact substitution — a malicious or compromised directory handing you a "ghost device" key before any pin exists. Shellcaster therefore binds a person's device keys to their identity, so a substituted key is caught at first contact too. Five mechanisms, layered, provide it. They mirror the package Apple (iMessage Contact Key Verification, 2023) and WhatsApp (Key Transparency, 2023) shipped to billions of users, built on primitives that already secure the web — not experimental work.

  • Cross-signing. Each account holds a long-lived identity signing key whose private half never leaves the owner's devices. Every device key carries a signature by that identity key; before wrapping, the host verifies it. Trust is hierarchical: a keyholder pins your identity key once and thereafter trusts every device it has signed, and you approve a new device of your own from an existing one (the identity key signs it) — no per-device involvement from collaborators. A ghost the operator injects has no valid signature, and the operator cannot produce one without your identity private key.
  • Key transparency. The identity→key directory is an append-only, publicly verifiable Merkle log (CONIKS / Trillian-style). Entries are indexed by a server-keyed function of the email — a keyed hash today, with a VRF (verifiable random function) the planned upgrade — so the log cannot be enumerated to reveal who holds an account; the log pins an account-level identity key. The log publishes signed checkpoints, and the protocol has clients verify O(log n) inclusion and consistency proofs, self-audit their own entry (the man-in-the-middle catch: a key listed under you that you never created), and gossip checkpoints so an equivocating operator — one that shows different trees to different people — is detected. (What is server-shipped versus staged in the client today is spelled out in the status note below.) This is the same primitive as Certificate Transparency, mandatory for every web TLS certificate since 2018, and precisely what Apple and WhatsApp deployed for messaging identity (WhatsApp open-sourced its Auditable Key Directory).
  • Out-of-band verification code. A short verification code is derived from both parties' identity keys (SHA-256 over the two public keys), so it is pairwise and stable. Reading it aloud on a call — or scanning a QR in person — confirms both keys at once with zero trust in any Shellcaster infrastructure; a substituted key makes the two computed codes disagree. It is opt-in, the deliberate belt-and-suspenders check for moments that warrant certainty, exactly as Signal's safety number and Apple's contact verification code work.
  • Proof-of-possession. Device registration includes a challenge proving the registrant holds the device private key. A stray database write therefore cannot mint a working ghost device: an inserted public key with no matching private key fails the challenge and is never usable. It folds into the sign-in and registration that already happen — no user-visible step.
  • Wrap-ahead acknowledgement. An opt-in "see their devices" inspection lets the host glance at the exact device set they are about to seal K_session to, on demand — surfacing an unexpected extra device before the wrap, without adding friction to the common path.

Everyday shape. None of this intrudes on normal use. Key transparency runs silently in the background and is always on; the everyday artifact is a quiet ✓ on a verified collaborator (today that ✓ reflects the directory's logged answer — see the status note for what the client independently verifies). The verification code appears only if someone chooses to verify; cross-signing's "approve a new device" sheet appears only when you set up a second Mac; proof-of-possession is invisible.

Implementation status (July 2026). The server side of all five mechanisms is shipped and exercised in CI (§14): identity keys with cross-signing enforced down to a refusal to store lockboxes for unsigned devices, the transparency log written in the same database transaction as every identity/device mutation with RFC 6962 proofs and signed checkpoints served, proof-of-possession at registration (via a companion Ed25519 signing key registered beside the unchanged X25519 encryption key — possession of that key is what the challenge proves), the verification-code nudge flow (the server carries the request only — never the code or the verdict), and identity replacement with re-approval fan-out. The client mints and signs automatically at sign-in, renders trust state throughout the sharing UI, and pins a verified identity so a later key change trips re-approval. Three pieces are staged behind it, tracked in §13: client-side proof verification (today the ✓ reflects the directory's answer; proofs are verified in CI rather than in the app), checkpoint gossip, and a proof-carrying VRF for the log index (today a server-keyed PRF — non-enumerable, but its correctness is not yet publicly checkable).
Recovery. If a keyholder loses every device, no remaining device holds the identity key to sign a new one, so they cannot self-vouch. Rather than let the operator quietly fill that gap — which is the attack — they start a fresh identity on the new device, and the people they share with re-approve it: a one-tap identity re-approval, reached just-in-time when the new device first tries to open a share, with a waiting state that persists per session until a host approves. Re-approval re-establishes trust in the new identity (not a per-device grant); once re-approved, any further devices the owner adds are cross-signed automatically. A recovery key that restores the old identity is a possible future option, deliberately off by default — it is one more secret to store and one more way in for an attacker.

9Realtime Signals

Live UX (presence, "new event", admission prompts, removal, session end) rides Ably pub/sub. Three properties keep it out of the trusted set:

  • Scoped capability tokens. Clients never hold the Ably API key. The server mints short-lived token requests scoped to exactly two channels: the session's channel and the caller's personal channel — after the roster check.
  • Signals are nudges, never data. Each signal names an event id; the client fetches and decrypts over the authenticated REST path. No content, plaintext or ciphertext, transits Ably. A full Ably compromise yields the metadata of §11, no more.
  • Optional by construction. REST replay is the source of truth; clients degrade to polling if the realtime layer is unavailable. Availability of signals is never traded against confidentiality.

10Algorithm Summary

PurposeAlgorithmParametersImplementation
Device identityX25519 keypair32-byte keyslibsodium (swift-sodium 0.11 / libsodium-wrappers)
Identity cross-signing, device proof-of-possession, approval grants (§8.4)Ed25519 detached signatures32-byte keys, 64-byte signatures; device vouching signs the domain-separated message "shellcaster-device-v1:" ‖ device_id ‖ device_pubkeyCryptoKit Curve25519.Signing / libsodium crypto_sign_*
Key-transparency log (§8.4)RFC 6962 Merkle tree over SHA-256 (0x00 leaf / 0x01 node prefixes); Ed25519-signed checkpointsO(log n) inclusion + consistency proofs; entries indexed by a server-keyed HMAC-SHA-256 of the email (proof-carrying VRF pending, §13)server-side, node crypto + libsodium; proofs verified in CI
Out-of-band verification code (§8.4)SHA-256 over "shellcaster-verify-v1" ‖ sort(identity_pk_A, identity_pk_B)10 chars, ambiguity-free charset (no 0/O/1/I/L), rendered 5·5; computed on-device, never transmittedCryptoKit / (client-only)
Session keyCSPRNG256-bit K_sessionlibsodium randombytes_buf
Key delivery (lockboxes, sealed key file)libsodium sealed box: X25519 + XSalsa20-Poly1305, BLAKE2b nonce derivation, ephemeral sender key48-byte overhead; 32-byte key → 80-byte boxcrypto_box_seal / _open
Content AEAD (events, blobs)XChaCha20-Poly1305 (IETF)24-byte random nonce, 16-byte tagcrypto_aead_xchacha20poly1305_ietf_*
Blob ids (private)HMAC-SHA-256 keyed by K_session64-hex outputApple CryptoKit / Node crypto; RFC 4231 vectors in CI
Blob ids (public), device fingerprints, path keysSHA-25664-hex / truncated displaysCryptoKit / Node crypto
OAuth code bindingPKCE S256 (RFC 7636)32-byte verifier, SHA-256 challengeCryptoKit; RFC vector in CI
API session tokensJWT, HMAC-SHA256 (HS256)30-day expiry, issuer/audience pinned, server-side session row requiredjsonwebtoken
Public identifiersOpaque, non-sequential, high-entropy serialsnot derivable from a neighbor; not countableserver-side
Event orderingULIDlexically time-ordered idsserver-side

Cross-implementation compatibility (Swift client ↔ TypeScript reference) is enforced byte-for-byte by shared fixtures and standard test vectors — see §14.

11What the Server Can & Cannot See

Honest metadata accounting is where E2E claims earn or lose credibility. For a private session:

The operator cannot obtain

  • Annotation text, comment bodies, reactions' targets-with-content, or any event payload plaintext.
  • Screenshot pixels or shared-file contents (S3 holds AEAD ciphertext).
  • K_session in any openable form: lockboxes and sealed key files are asymmetric ciphertext addressed to device keys the server never sees.
  • Confirmation that a session contains a known document (blob ids are keyed, §7.3).

The operator can observe

MetadataWhereWhy it exists
Who: roster emails/handles, host identity, join times, device names + public keysDBIdentity-based admission and billing are the product model
That a view is shared, and its blinded identity (a keyed hash) — not its path or URLDB (exhibits)Durable annotation anchoring across sessions and devices, without the plaintext (§7.5)
The session's share-time label, when the host keeps one — a name the host reviews (prefilled with the project folder name) and may edit before startingDB (casts)Deliberate, consent-framed disclosure: discovery lists, the waiting room, and invite emails can name a session before the recipient holds K_session; invite email transits plaintext regardless, so in-app blinding of this one string bought no real secrecy
Event types, authors, timestamps, sizes, orderingDB (event envelopes)Stream ordering, catch-up cursors, lifecycle
Blob ids (keyed), sizes, upload timing; view → current-blob pairingDB + S3Storage lifecycle, dedup, late-join fast path
Activity rhythm: when events flow, session start/end, who is onlineDB + AblyInherent to relaying
Directory lookups: who asked for whose device keys, whenAudit journalDeliberate audit trail for the one sensitive disclosure path
Plainly: the operator can know that Alice shared a file with Bob on Tuesday and that they exchanged nine encrypted comments and four file versions — and cannot know what any of it said or contained, nor even the file's name: the shared path is stored blinded as a keyed hash (§7.5), so the operator sees that a view is shared, not which file or URL. The one name the operator may see is the session's share-time label — a string the host reviews in the share sheet, prefilled with the project name and edited only if they choose; file and URL names stay blinded regardless of that choice.

12Risks & Mitigations

RiskMitigationResidual exposure
Full server / database compromiseNo keys server-side; payloads AEAD ciphertext; lockboxes sealed to device keys; keyed blob ids defeat known-content confirmation§11 metadata
S3 bucket exposureObjects are nonce‖ciphertext; bucket keys are session-scoped; presigned URLs are single-object and short-lived, issued post-roster-check and re-minted on demand — so URL expiry never revokes a roster member's accessObject sizes and timing
Stolen or leaked join linkLinks are non-secret locators; admission requires signing in as a roster-named, IdP-attested emailNone (reveals a session serial exists)
Stolen API tokenKeychain storage; server-side session-row authority — revocation/idle-timeout kills tokens before JWT expiry; tokens grant no decryptionAPI-level identity impersonation until revoked; content requires the victim's device key too
Malicious/compromised directory inserting a "ghost device" keyThe identity layer (§8.4) binds every device key to a cross-signed identity key and publishes it in an append-only transparency log designed for client verification and self-audit (server side shipped; client-side proof checking and gossip staged — §13); TOFU pinning refuses later changes; wrap decisions are client-side; fingerprints and an out-of-band verification code allow independent confirmation; directory reads auditedToday the strongest live client-side defenses are TOFU pinning and the out-of-band verification code; the log's client-verified anti-equivocation properties land as §13 completes
Removed participant retaining K_sessionRemoval semantics are rekey semantics (§8.3): hard 403 on the API immediately; fresh key for survivors; old stream carries nothing newContent shared before removal — disclosed by definition
Client downgrade (plaintext on a private session)Server rejects encrypted:false writes to private sessions; private publish paths refuse to run keyless rather than fall back
Nonce misuse under one key24-byte XChaCha nonces from a CSPRNG; per-event and per-blob; collision probability negligible at any feasible volume
Cross-implementation crypto driftBoth implementations verified against shared fixtures byte-for-byte, both directions, in CI-runnable harnesses (§14)
Resource enumeration / scrapingNon-sequential, high-entropy serials (not countable, not guessable from a neighbor); roster gate on every session route; identified-only surface
Oversized/abusive payloads64 KB event cap (DB-enforced), 100 MB blob cap, lockbox size bounds, snapshot debounce + client-side 32 MB cap
Realtime-layer compromiseScoped tokens; signals carry ids and admission metadata only, never contentPresence/timing metadata

13Known Limitations & Roadmap

Several are deliberate scope decisions with designed successors.

  1. No independent audit yet. This paper is implementation-accurate and the verification suite is reproducible (§14), but no third-party cryptographic audit has been performed. One is planned in the months ahead, as Shellcaster moves from Beta to official release.
  2. No reproducible builds / binary transparency. Users trust the shipped binary, as with mainstream E2E messengers today.
  3. Transparency verification is server-proven, not yet client-proven. The log, proofs, and signed checkpoints exist and are re-verified independently in CI (§14), but the shipped client does not yet check inclusion/consistency proofs itself, checkpoints are not yet gossiped between clients, and the log index is a server-keyed PRF rather than a proof-carrying VRF. Until those land, the in-app ✓ attests "the directory's answer is logged," with the log's honesty checked out-of-band rather than on-device. Each is an additive upgrade against the same schema and endpoints (§8.4).
  4. Offline admission requires a live keyholder (strict by default). A future release may introduce an opt-in password option: the host sets a per-session password (shared out-of-band) and K_session is sealed under Argon2id(password), so an invitee can unlock it with no keyholder online — a deliberate, labeled bend of "no key-shaped bytes server-side," off by default.
  5. Metadata retention policy is unstated. Event envelopes and audit journals currently persist indefinitely (content blobs on unbacked sessions age out in 24 hours, and on backed sessions in 30 days). A retention schedule belongs in the enterprise data-processing terms.
  6. The host's platform is part of the TCB. Anything rendered on screen can be captured by software the user runs; this is inherent to the product category.

14Verification & Reproducibility

The security-relevant behavior is exercised by four independent harnesses, all runnable from the repository against a live dev server:

HarnessWhat it proves
Two-user reference harness (TypeScript)32 checks over real HTTP + S3: full private-session flow — wrap-ahead lockbox delivery, K_session recovery, AEAD round-trips, keyed blob ids, join-twice-free, non-roster 403 / anonymous 401, and direct DB inspection asserting only ciphertext was stored
Swift client harness (production code paths)23 checks driving the shipped client crypto/transport: serial joins, roster removal → immediate 403 → re-invite re-grant, latest-content pointer, post-end write refusal
Cross-implementation fixturesByte-for-byte interop of every primitive in §10, verified in both directions — a fixture minted by one implementation is checked into the other's test suite; plus RFC 4231 (HMAC) and RFC 7636 (PKCE) standard vectors
API test suite73 tests including: encryption-flag-matches-visibility enforcement, payload caps, lockbox ownership checks, §8 roster/removal semantics, two-door admission classification, credit accounting
Identity & transparency suite (§8.4)13 tests with real Ed25519 end to end: proof-of-possession challenge / wrong-key refusal / nonce replay, cross-sign verification and bad-signature rejection, the unsigned-device lockbox refusal, identity replacement chaining, RFC 6962 inclusion proofs re-verified client-side against the signed checkpoint root, intent-scoped lookups (stranger gets the no-oracle empty shape) with journaled quota, verification-nudge lifecycle, and the device-approval state machine

The reference harness is deliberately written as a readable, self-contained specification of the entire crypto contract — it is the oracle the Swift client is held to.

15Disclosure Policy

We want to hear from you before anyone else does. Report suspected vulnerabilities to security@shellcaster.com. We review incoming reports and, for legitimate findings, keep reporters informed through remediation and credit researchers who wish to be named. Please practice coordinated disclosure; we will not pursue action against good-faith research conducted against your own accounts and sessions.

Document history: v1.0 (July 2026) — initial publication, written against the beta implementation.

Security Whitepaper — Shellcaster