Everything under the hood: how to run it locally, the architecture, the networking model, storage, and the current limitations. For the end-user overview, see the main README.
SpellCast is built with vanilla JavaScript (ES modules, no build step) and a couple of small libraries:
- PeerJS — WebRTC connections for peer-to-peer communication
- IndexedDB — persistent storage for messages, media, peers, circles, and identity keys
- Web Crypto API — browser-native cryptography for message signing, identities, and circle-message encryption (no crypto library is bundled)
- QRCode.js — generating the QR codes used to share an invite link
- jsQR — vendored locally (
src/jsQR.min.js) and lazy-loaded only when the in-app camera scanner is opened, to decode QR codes in browsers without the nativeBarcodeDetectorAPI
-
Clone the repository:
git clone https://github.com/SiENcE/spellcast.git -
Serve the project with a local web server, then open it in a modern browser (Chrome, Firefox, Edge, or Safari).
A local server is required. SpellCast uses native ES modules (
<script type="module">), which browsers refuse to load overfile://. Openingindex.htmldirectly from the file system will not work.- On Windows, run the included
start.bat(serves on port 8080 via Python), then browse tohttp://localhost:8080/src/. - Or start a server manually from the repository root:
# Python python -m http.server 8080 # Node.js npx http-server
- On Windows, run the included
-
Open the served page (e.g.
http://localhost:8080/src/). Edit a file and refresh the browser to see changes — there's no build step.
The application is structured into a set of manager classes, wired together by the main controller:
- SpellCastApp (
app.js) — main application controller and bootstrap - UserManager — username, peer id, and the cryptographic identity (loads/mints the keypairs)
- CryptoIdentity (
crypto-identity.js) — all cryptography: keypair generation, message signing/verification, the multi-recipient sealed box for circle posts, the passphrase-encrypted credential backup, and the CSPRNG id helper - PeerManager — peer connections, reconnection, message routing, and per-peer abuse resistance (rate limiting, strikes, blocklist)
- TweetManager — message creation, validation, signing/verification, encryption of circle posts, storage, distribution, and reactions
- MediaManager — image processing (resize/thumbnail, client-side compression) and storage
- CircleManager — local, named groups of peers
- StorageManager — persistent storage via IndexedDB (with a localStorage/cookie migration path)
- UIManager — DOM rendering and event handling
- RateLimiter — reusable sliding-window throttle used for connection, message, and abuse limits
There's also a small link-preview helper that turns URLs into clickable links
and renders previews (inline images, YouTube thumbnails, or a favicon/domain
"link card") using only browser-native techniques — no scraping proxy.
SpellCast forms a mesh network:
- Direct connections — you establish WebRTC connections with peers you know.
- Message propagation (multi-hop relay) — when a message is sent:
- It's signed with the author's key (see Security model), then stored in local browser storage.
- It's sent to all directly connected peers.
- Each peer that receives a new message verifies its signature, then stores it and forwards (relays) it to its own peers, so messages travel multiple hops across the network. A relay forwards the original author's signature unchanged — it cannot alter the message without invalidating it.
- The system tracks which peers have received each message, so a peer is never sent the same message twice — preventing duplicates and relay loops — and a hop/fan-out cap bounds how far and wide any single message spreads.
- Image propagation — attached images travel with the message (a thumbnail for the feed plus the full-size image) and are stored in each receiving peer's IndexedDB, so images survive reloads and propagate across hops like text.
- Reconnect sync (pull-based) — whenever two peers connect, each asks the other for anything it's missing (sending the list of message IDs it already has). The other peer replies with the rest, images included. This is mutual, so a user who just logged in — even on a fresh device or after clearing local data — gets the full history and images back from any peer that has them.
There's no central point of failure, and messages can reach their destination through alternative paths in the network.
To start talking, two peers need to exchange one peer id. SpellCast makes that typing-free:
- QR codes encode a deep link, not the bare id —
<this app's URL>?connect=<peerId>. Scanning it with a phone's native camera therefore opens SpellCast (wherever it is served) rather than feeding a meaningless string to a search engine. On load, the app reads the?connect=parameter, strips it from the URL (so a refresh doesn't reconnect), and — once you're logged in — offers to connect to that peer. - In-app scanner. The Connect tab has a "Scan QR" button that opens the rear
camera and decodes a peer's QR directly, using the native
BarcodeDetectorwhere available and the vendoredjsQRdecoder otherwise. - Invite links. The profile offers Share invite link (via the OS share sheet
on mobile, using the Web Share API)
and Copy link, so the same
?connect=URL can be sent over any messenger. The connect field also accepts a pasted invite link or a raw id interchangeably.
These are convenience transports for a public routing id — they carry no secret (your security comes from the signed identity, not from the id staying private).
The distribution layer tracks which peers hold which messages and which messages still need to be delivered to which peers:
// Simplified message distribution tracking
{
tweetRecipients: {
"message-123": ["peer1", "peer3"], // Peers that have this message
"message-456": ["peer1", "peer2", "peer3"]
},
unsentTweets: {
"peer1": [], // This peer has all messages
"peer2": ["message-123"], // This peer needs message-123
"peer3": []
}
}When a peer connects, the pull-based sync uses the peer's reported list of known message IDs to decide exactly what to send — which is robust even if local tracking is stale across sessions.
Circles are a local, per-device construct: each client defines its own circles and membership.
- A public post is broadcast to all peers and is relayed + synced normally, in the clear (it is meant to reach everyone).
- A circle post is sent only to that circle's currently-connected members and is tagged with the audience name. It is end-to-end encrypted to each recipient's key (see Confidential circle messages). Circle posts are intentionally not multi-hop relayed or bulk-synced, so the encrypted form is the only form on the wire and they stay within their audience.
- Feed filtering matches a message's author peer ID (
authorId) against a circle's members, which stays consistent regardless of what other clients name their own circles. The global "All Peers" feed shows only public posts; circle posts appear under their circle.
SpellCast layers several independent protections so that — even with no server to vouch for anyone — messages are authenticated, impersonation is detectable, and private-audience posts are end-to-end encrypted. This section describes each layer, how it is implemented, and (at the end) what it deliberately does not protect. All cryptography uses the browser-native Web Crypto API; no crypto library is bundled.
- Protected: forging a message as another identity; tampering with a message in transit or at a relay; reading a circle (private-audience) post if you are not one of its recipients; passive eavesdropping on the WebRTC data channel; trivial flooding/DoS from a single misbehaving peer.
- Not protected (by design): the contents of public posts (they are meant to reach everyone); connection metadata visible to the signaling broker (who connects to whom); a legitimate circle recipient choosing to re-share what they received; network-layer anonymity. See What the security model does not cover.
Identity is a keypair, not a name. On first run (or when new credentials are created) each
client generates two P-256 keypairs via crypto.subtle.generateKey:
- an ECDSA P-256 keypair used to sign messages, and
- an ECDH P-256 keypair used to receive encrypted circle posts.
P-256 is used (rather than Ed25519/X25519) because it is supported by every
current browser's Web Crypto implementation. The two public keys are what
travel to other peers; the private keys live in IndexedDB as CryptoKey objects.
Legacy credentials created before this existed transparently mint a keypair on first
run of an updated build.
The signing public key is the real identity. The username is only a
self-asserted display label and the PeerJS id is only a routing address. The
human-facing handle shown on every message and on the profile is
name#fingerprint — e.g. alice#a248 — where the 4-character fingerprint is a
fast hash of the public key. The fingerprint is a visual disambiguator, not the
security boundary (the real checks are the signature and the full-key pin below);
the avatar colour is also seeded from the public key, so two people who pick the
same name look different.
Secure context required. Web Crypto is only available over
https://orhttp://localhost. Served over a plainhttp://LAN address, key generation is unavailable and the app falls back to unsigned messages (everything shows as "unverified"). Always serve SpellCast over HTTPS or localhost.
Every outbound message is signed. The signature covers a canonical,
fixed-order encoding — a JSON array prefixed with the domain string
spellcast-tweet-v1 — of the security-relevant fields:
[ "spellcast-tweet-v1", authorKey, username, content, timestamp, id, mediaId, circle ]
The fixed order removes any object key-ordering ambiguity between signer and
verifier, and the domain prefix stops a signature from one context being replayed
in another. The message carries the author's public key (authorKey) and the
signature alongside the content.
On receipt — before a message is stored, shown, or relayed — the verifier
recomputes that encoding from the received fields and checks the signature against
authorKey (crypto.subtle.verify):
- valid signature → accepted and marked verified;
- present but invalid signature → dropped (forged or tampered), and the sending peer earns an abuse "strike" (see below);
- no signature (an older/legacy peer) → accepted but shown as unverified.
Because signing is over the plaintext fields and authorKey+signature ride
along unchanged, a relay cannot alter a message without invalidating the
signature, and it forwards the original author's signature rather than re-signing
as itself. Reactions ("Sparks") are signed the same way under the separate domain
spellcast-reaction-v1, so reaction counts cannot be forged or attributed to
someone else's key.
You cannot stop someone from typing the display name "alice" in a server-less system — that is the Zooko's Triangle trade-off (an identifier can be at most two of: human-meaningful, decentralized, secure). SpellCast resolves it by making the key unforgeable and binding the name to a key by trust-on-first-use (TOFU):
- The first time a verified message from key K calls itself "alice", the client
pins
alice → Kin a persisted name registry. - A later, different key claiming a name already pinned to someone else is flagged in the UI with a "⚠ impersonator?" badge instead of appearing as a plain, trusted "alice".
- The client also pins its own name→key, so an impostor using your name is flagged on others' screens.
This is the same idea as SSH known_hosts and end-to-end messengers' safety
numbers.
Public posts are intentionally cleartext. Circle posts (sent to a named, private audience) are instead end-to-end encrypted to each recipient's key using a multi-recipient sealed box — an ECIES-style construction:
- A fresh random AES-256-GCM content key encrypts the payload once. The encrypted payload includes the message body and any attached image (thumbnail + full image); only routing fields (id, timestamp, author key, signature, circle name) stay in clear.
- For each recipient, the sender performs ephemeral-static ECDH — a brand-new ephemeral keypair per message against that recipient's static ECDH public key — derives a wrapping key (SHA-256 of the shared secret), and wraps the content key for that recipient. Each recipient gets an envelope only they can open.
- The fresh ephemeral key per message provides forward secrecy, and because every message is sealed to the current member list there is no long-lived group key to manage — removing a member simply excludes them from future posts.
The author still signs the plaintext, so a recipient verifies authorship after decrypting. Recipients' ECDH public keys are learned during the connection handshake. Circle posts are never relayed or bulk-synced (they go directly to targeted members), so the sealed form is the only form on the wire. If a member's client is too old to advertise an encryption key, that member is skipped for an encrypted post; if no recipient supports encryption the post falls back to cleartext (with a console warning).
Because the identity is a private key held in one browser, it can be exported as a
passphrase-encrypted backup and restored on another device from the login
screen. Two transports carry the same encrypted envelope: a backup file
(Profile → Export Credential Backup → Login → Import Credential Backup) and a
backup QR (Profile → Show Backup QR → Login → Scan Backup QR, using the
same camera scanner as the connect flow). The backup wraps both private keys (plus
username and peer id) with a key derived from your passphrase using
PBKDF2-HMAC-SHA-256 (250,000 iterations) → AES-256-GCM; it is useless without
the passphrase. To keep the QR a single, easily-scanned code, the envelope is
compact (format v2): it stores only the raw private scalars and public keys
(~0.7 KB) and reconstructs the full key material on import — older v1 files,
which embedded full JWKs, still import. Trade-off: to be exportable the private
keys are generated extractable, so script running in the page (e.g. via an XSS)
could in principle read them — which is exactly why the Content-Security-Policy
below locks scripting down hard, and why the backup is always encrypted.
Beneath the application-level signing and encryption, the transport is already encrypted: WebRTC data channels use DTLS (Datagram Transport Layer Security — the datagram cousin of TLS/HTTPS), negotiated with a secure handshake when two peers connect. DTLS protects data between directly connected peers; the application-level signatures and circle encryption extend protection across relays and storage, which DTLS alone does not.
index.html ships an in-page Content-Security-Policy. The key directive is
script-src 'self' https://cdnjs.cloudflare.com with no 'unsafe-inline' and
no 'unsafe-eval' (PeerJS ≥ 1.5 no longer needs eval), so injected markup
cannot execute script — defense-in-depth behind the app's text-only DOM rendering.
The full policy:
default-src 'self'; base-uri 'none'; object-src 'none'; form-action 'none';
script-src 'self' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline';
img-src 'self' data: https:; font-src 'self'; connect-src 'self' https: wss:
img-src … https:is required because link previews load remote favicons / YouTube thumbnails; message media is still restricted to inlinedata:URIs by message validation.connect-src … https: wss:allows the PeerJS signaling broker (WebRTC's own STUN/TURN traffic is not governed by CSP).style-src 'unsafe-inline'is retained for a few static inline styles and the QR widget; low-risk because scripting is fully locked down.
The CDN <script> tags also carry
Subresource Integrity
hashes (integrity="sha512-…"), so a tampered CDN file is refused, and
<meta name="referrer" content="no-referrer"> keeps the page URL out of the
Referer header on remote image loads. One protection cannot be expressed in a
<meta> CSP: frame-ancestors (anti-clickjacking) must be sent as a real
Content-Security-Policy: frame-ancestors 'none' (or X-Frame-Options: DENY)
HTTP response header by whatever serves the page.
Several limits bound what one malicious or buggy peer can do. They run in
PeerManager / TweetManager / MediaManager:
- Inbound rate limiting — each peer is limited to ~400 messages per 10-second window; excess is dropped, and every inbound message is shape-checked before dispatch.
- Strike & blocklist — a peer sending invalid/forged/oversized payloads (or flooding) accrues "strikes"; 10 strikes within 60 s disconnects it and blocklists it for 5 minutes.
- Relay caps — a relayed message has a hop limit (6) carried in a
transport-only
hopscounter and a fan-out cap (32 peers); together with the per-message recipient tracking (which already prevents loops/duplicates) this bounds broadcast storms. - Input validation & size caps — incoming messages are strictly validated:
unknown fields are rejected; username ≤ 64 chars; content ≤ 1000 chars; a media
thumbnail must be an inline
data:image/…URI ≤ 64 KB (never a remote URL, which would beacon the viewer's IP); a full image from a peer is capped at 5 MB; a bulk-sync message is capped at 500 messages. - Bounded media storage — the local media store is capped at 500 items (checked cheaply by key count), so a peer cannot fill your disk; once full, incoming media is refused.
- CSPRNG everywhere it matters — all non-trivial identifiers (peer ids, media
ids, circle ids) come from
crypto.getRandomValues, notMath.random.
This throttling is client-side: it protects an honest client from others, but a modified client can ignore its own limits. Forgery is prevented by the signature checks, not by throttling.
By default SpellCast uses the public PeerJS cloud broker for signaling. The
broker never sees message content, but it does see peer ids and who connects to
whom. To keep that metadata off third-party infrastructure, point the app at your
own broker by filling in the CUSTOM_PEER_SERVER
(host/port/path/key/secure) constant near the top of peer-manager.js
(run one with npm install -g peer then peerjs --port 9000 --key <key>). The
adjacent CUSTOM_TURN_SERVERS constant adds authenticated TURN relays for peers
behind symmetric NAT (the app ships with public STUN only). Both default to off,
so behaviour is unchanged until configured; when set, every connection path
(new-credentials, login, fallback) honours them.
- Public posts are not confidential. Only circle posts are encrypted; public ("All Peers") posts are cleartext by design so they can propagate everywhere.
- The peer id is still address + login. Anyone who learns your peer id can reconnect to the broker under it. They cannot forge your signed messages (that needs your private key), but the routing identifier itself is not a secret — treat it as a contact handle and rely on the signed identity, not the peer id, for trust.
- Recipients are trusted. End-to-end encryption stops outsiders and relays, not a legitimate circle member who chooses to re-share or screenshot a message.
- Metadata & anonymity. The broker (and your network) can observe connection metadata; SpellCast is not an anonymity system.
- Deletes are local and not tombstoned. Deleting a message removes it from your device only; a peer that still holds it may re-share it on a later sync.
- Unverified/legacy messages are shown, not blocked. For interoperability, unsigned messages from older peers are displayed (clearly marked "unverified") rather than dropped, so a mixed-version network still works.
SpellCast uses a single IndexedDB database (with a legacy localStorage/cookie migration path) to persist:
- User credentials — username and peer ID
- Identity keys — your ECDSA signing and ECDH encryption keypairs, stored as
Web Crypto
CryptoKeyobjects (the private keys are never serialized to plain text in storage — IndexedDB persists the key objects directly) - Name registry — the trust-on-first-use pins (
username → first verified public key) that drive impersonation detection - Messages — all created and received messages (each with its author key and signature)
- Reactions — signed "Spark" reaction records, keyed by message
- Media — images attached to messages (stored as base64 data URLs)
- Peers — known connections and their status
- Circles — your local peer groups
- Distribution state — which peers have received which messages
The main store and the media store share one database version to avoid concurrent open-at-two-versions conflicts. Delete Credentials and the data-reset actions clear the identity keys and name registry along with everything else.
SpellCast actively monitors connection health:
- Regular pings are sent to connected peers.
- Response times are measured to gauge connection quality.
- Failed connections trigger automatic reconnection attempts.
- Connection quality is shown in the UI with a simple indicator.
Contributions are welcome:
- Fork the repository — create your own copy.
- Make your changes — add features or fix bugs.
- Test thoroughly — verify across different browsers.
- Submit a pull request — share your improvements.
- Use clear, descriptive variable and function names.
- Add comments for complex logic.
- Maintain the existing code structure and patterns.
- Write clean, modular code.
- Test your changes across different browsers.
SpellCast is a prototype. The cryptographic protections in the Security model are real, but several product-level constraints remain (and are good contributor starting points):
- The peer id is still an address and a login. Anyone who learns it can reconnect to the broker under it. They cannot forge your signed messages (that needs your private key), but the routing id itself is not secret — trust the signed identity, not the peer id.
- Only circle posts are encrypted. Public ("All Peers") posts are cleartext by design so they can propagate across the whole mesh. Encryption also protects a circle post only from outsiders and relays, not from a legitimate recipient who re-shares it.
- Images are sent inline. A thumbnail and the full image travel with the message (and during reconnect sync). Large photos are accepted (up to 15 MB) and automatically downscaled/recompressed client-side to roughly 600 KB before sending, but a feed full of images still costs bandwidth.
- Local deletes are not tombstoned. Deleting messages affects only your device; a peer that still has a message may re-share it on a later connection.
- Link previews load remote content. Rendering a preview (image, YouTube
thumbnail, or favicon) fetches it from its origin, revealing your IP to that
domain — including for links in messages you received. The page sends no
Referer(so the page URL doesn't leak), but the request itself is unavoidable without a proxy, which would break the serverless model. True Open Graph previews (scraped title/description) are not done for the same reason. - Circle feed filtering uses author info. Messages are matched to a circle by the author's peer ID; messages from very old builds without an author ID only appear under "All Peers".
- Throttling is client-side. The rate limits, strikes, and blocklist protect an honest client from others, but a modified client can ignore its own limits. Forgery is prevented by signature checks, not by throttling.
- Signaling still depends on a broker. Connectivity uses the public PeerJS
cloud broker by default (retrying with alternate STUN configuration if it is
unreachable). You can self-host a broker and TURN relay via the config constants
in
peer-manager.js(see Broker / network-metadata privacy & self-hosting), but there is no automatic independent signaling fallback.