Shellcaster Security Whitepaper
§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).
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
| Adversary | Capability assumed | Goal defended against |
|---|---|---|
| Curious / compelled operator | Full read access to the API server, database, S3 buckets, Ably account, and all traffic between them | Reading private session content |
| Database exfiltration | A complete dump of PostgreSQL | Recovering content, session keys, or confirming known documents |
| Storage compromise | Read access to all S3 objects | Reading screenshots or shared files |
| Network observer | Passive observation of client↔server traffic (assume TLS may terminate at intermediaries such as tunnels or load balancers) | Reading content in transit |
| Authenticated outsider | A valid Shellcaster account that is not on a session's roster; possession of a session's join link | Joining, reading events/blobs, probing for content |
| Removed participant | A former roster member who retains the old session key | Reading anything shared after removal |
| Invite-link leak | A forwarded, logged, or intercepted join link | Any 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
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):
- The app binds a transient HTTP listener to
127.0.0.1on an ephemeral port (loopback only — never an external interface). - 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 randomstate. - Google redirects to the loopback listener; the app validates
stateand captures the one-time authorization code. - 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_verifiedclaim 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
noncein itsstate, mirrored into an HttpOnly,SameSite=Laxcookie. 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
redirectis 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:
- 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.
- 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.
- 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.
- 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 ‖ ciphertextobject 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. - 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):
| Case | Server-supplied classification | Intended client handling |
|---|---|---|
| First device of a newly invited person | firstDeviceForUser: true | Silent 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 device | selfVoucherPossible: true | The 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 possible | neither flag | Loud 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.
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
| Purpose | Algorithm | Parameters | Implementation |
|---|---|---|---|
| Device identity | X25519 keypair | 32-byte keys | libsodium (swift-sodium 0.11 / libsodium-wrappers) |
| Identity cross-signing, device proof-of-possession, approval grants (§8.4) | Ed25519 detached signatures | 32-byte keys, 64-byte signatures; device vouching signs the domain-separated message "shellcaster-device-v1:" ‖ device_id ‖ device_pubkey | CryptoKit Curve25519.Signing / libsodium crypto_sign_* |
| Key-transparency log (§8.4) | RFC 6962 Merkle tree over SHA-256 (0x00 leaf / 0x01 node prefixes); Ed25519-signed checkpoints | O(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 transmitted | CryptoKit / (client-only) |
| Session key | CSPRNG | 256-bit K_session | libsodium randombytes_buf |
| Key delivery (lockboxes, sealed key file) | libsodium sealed box: X25519 + XSalsa20-Poly1305, BLAKE2b nonce derivation, ephemeral sender key | 48-byte overhead; 32-byte key → 80-byte box | crypto_box_seal / _open |
| Content AEAD (events, blobs) | XChaCha20-Poly1305 (IETF) | 24-byte random nonce, 16-byte tag | crypto_aead_xchacha20poly1305_ietf_* |
| Blob ids (private) | HMAC-SHA-256 keyed by K_session | 64-hex output | Apple CryptoKit / Node crypto; RFC 4231 vectors in CI |
| Blob ids (public), device fingerprints, path keys | SHA-256 | 64-hex / truncated displays | CryptoKit / Node crypto |
| OAuth code binding | PKCE S256 (RFC 7636) | 32-byte verifier, SHA-256 challenge | CryptoKit; RFC vector in CI |
| API session tokens | JWT, HMAC-SHA256 (HS256) | 30-day expiry, issuer/audience pinned, server-side session row required | jsonwebtoken |
| Public identifiers | Opaque, non-sequential, high-entropy serials | not derivable from a neighbor; not countable | server-side |
| Event ordering | ULID | lexically time-ordered ids | server-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
| Metadata | Where | Why it exists |
|---|---|---|
| Who: roster emails/handles, host identity, join times, device names + public keys | DB | Identity-based admission and billing are the product model |
| That a view is shared, and its blinded identity (a keyed hash) — not its path or URL | DB (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 starting | DB (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, ordering | DB (event envelopes) | Stream ordering, catch-up cursors, lifecycle |
| Blob ids (keyed), sizes, upload timing; view → current-blob pairing | DB + S3 | Storage lifecycle, dedup, late-join fast path |
| Activity rhythm: when events flow, session start/end, who is online | DB + Ably | Inherent to relaying |
| Directory lookups: who asked for whose device keys, when | Audit journal | Deliberate audit trail for the one sensitive disclosure path |
12Risks & Mitigations
| Risk | Mitigation | Residual exposure |
|---|---|---|
| Full server / database compromise | No keys server-side; payloads AEAD ciphertext; lockboxes sealed to device keys; keyed blob ids defeat known-content confirmation | §11 metadata |
| S3 bucket exposure | Objects 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 access | Object sizes and timing |
| Stolen or leaked join link | Links are non-secret locators; admission requires signing in as a roster-named, IdP-attested email | None (reveals a session serial exists) |
| Stolen API token | Keychain storage; server-side session-row authority — revocation/idle-timeout kills tokens before JWT expiry; tokens grant no decryption | API-level identity impersonation until revoked; content requires the victim's device key too |
| Malicious/compromised directory inserting a "ghost device" key | The 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 audited | Today 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_session | Removal semantics are rekey semantics (§8.3): hard 403 on the API immediately; fresh key for survivors; old stream carries nothing new | Content 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 key | 24-byte XChaCha nonces from a CSPRNG; per-event and per-blob; collision probability negligible at any feasible volume | — |
| Cross-implementation crypto drift | Both implementations verified against shared fixtures byte-for-byte, both directions, in CI-runnable harnesses (§14) | — |
| Resource enumeration / scraping | Non-sequential, high-entropy serials (not countable, not guessable from a neighbor); roster gate on every session route; identified-only surface | — |
| Oversized/abusive payloads | 64 KB event cap (DB-enforced), 100 MB blob cap, lockbox size bounds, snapshot debounce + client-side 32 MB cap | — |
| Realtime-layer compromise | Scoped tokens; signals carry ids and admission metadata only, never content | Presence/timing metadata |
13Known Limitations & Roadmap
Several are deliberate scope decisions with designed successors.
- 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.
- No reproducible builds / binary transparency. Users trust the shipped binary, as with mainstream E2E messengers today.
- 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).
- 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.
- 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.
- 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:
| Harness | What 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 fixtures | Byte-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 suite | 73 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.