Pinwheel FreeChat
How it works Install What's inside Download
Post-quantum E2EE · drop-in · self-hosted

A chat widget that actually keeps the server blind.

Pinwheel FreeChat is a floating group-chat widget you can drop into any PHP site. Client-generated ML-KEM-1024 (post-quantum) keypairs, a group secret only members ever see unwrapped, and an AES-256-GCM message ratchet that rotates every 20 seconds — the server relays ciphertext it structurally cannot read.

Free Download (.zip) See how it works Read the README
Try it right now — register or continue as guest in the widget, bottom-right corner. Real E2EE, running on this page's own demo database.

Pinwheel encryption diagram
Why this exists

Built to be dropped in, not built around

One CSS file, one JS file, one small PHP API. It builds its own UI, handles its own auth, and runs its own key exchange — no framework, no build step, no external service.

Two files to include

A stylesheet and a script tag. The widget injects its own floating panel into the page on load — nothing else to wire up.

Real E2EE, not marketing E2EE

The server never holds the group secret or any client's private key. It relays opaque AES-GCM ciphertext, full stop.

Accounts or anonymous

Username + PIN accounts, or a one-tap throwaway guest identity — both run through the exact same encrypted room.

Just MySQL

Four tables, one schema.sql. No message queue, no Redis, no third-party chat backend to stand up.

Multi-site rooms

Point two different sites' configs at the same database and they share one live room automatically. Same accounts, same messages.

Anti-scraping by design

Messages render as small rasterized PNGs client-side, not plain text — a deliberate property carried through from the original build.

Pentested and hardened

CSRF, rate limiting, and IDOR checks on every session-scoped endpoint — including a fix for IP-spoofing that used to bypass rate limits entirely, and self-healing when a cached CSRF token goes stale.

Under the hood

Two independent encryption layers

Layer 1 is the real confidentiality boundary. Layer 2 is optional and explicitly server-visible — it exists to let the server itself verify a PIN, something pure client-side E2EE structurally cannot do.

1

Group secret, ML-KEM-1024-wrapped per member true E2EE · post-quantum

The first member of a room generates a random 256-bit group secret entirely client-side. As each new member joins, an already-onboarded member encapsulates a fresh shared secret to the new member's ML-KEM-1024 public key (FIPS 203, the NIST-standardized successor to CRYSTALS-Kyber), then AES-256-GCM-wraps a copy of the group secret under a key derived from that shared secret. The server stores and forwards these wrapped blobs — it never sees the secret itself, wrapped or not, and the exchange stays secure even against a future quantum computer, unlike RSA or elliptic-curve key exchange.

2

HKDF message ratchet, 20-second epochs true E2EE

The group secret derives a fresh "epoch key" every 20 seconds, and each epoch derives a unique per-message key. Every message is AES-256-GCM encrypted under a key that's used exactly once. A server authenticity tag (HMAC, rotated per poll) lets clients verify a response really came from the real server — without giving the server anything it could use to read the message.

3

Optional PIN layer server-mediated

If a user sets a local PIN, it's RSA-encrypted to a per-session server keypair and used to derive a one-time "secureId" key that re-encrypts the already-encrypted message again before storage. This is explicitly not end-to-end — the server sees the PIN in the clear by design, which is what lets it verify a PIN belongs to a specific sender on request. Entirely optional, per message, off by default.

Getting started

Five steps to a running chat room

No build tooling, no package manager. Copy files, fill in four config values, run one SQL file.

Copy the folder in

Drop chat/ and freechat/ at the same directory level as the page(s) that should show the widget.

Point it at a database

Edit chat/config.php — fill in DB_HOST, DB_NAME, DB_USER, DB_PASS.

Create the tables

mysql -u root -p your_db_name < chat/schema.sql

Every statement is CREATE TABLE IF NOT EXISTS — safe to re-run.

Include the widget

<link rel="stylesheet" href="freechat/freechat.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
...
<script src="freechat/js/freechat-integration.js"></script>

That's it. The widget builds its own floating button and panel — no other markup needed.

Serve it over HTTPS

crypto.subtle (Web Crypto) only exists in a secure context — https://, or http:// on localhost/127.0.0.1 for local dev. On a plain http:// LAN address the widget shows a clear setup error instead of failing silently.

What's inside

Everything the widget needs, nothing it doesn't

freechat/
  freechat.css                  # widget styling
  js/freechat-integration.js    # self-injecting widget
  js/vendor/mlkem/               # crystals-kyber-js v2.7.0 (ML-KEM-1024), vendored
chat/
  config.php                    # DB credentials + CSRF/rate-limit helpers
  schema.sql                    # the 4 tables (+ rate_limits)
  lib/
    secure_chat.php              # server-side Pinwheel protocol
    session.php                  # shared PHP-session auth helpers
  api/
    register.php  login.php  guest.php  logout.php  me.php
    secure_chat_create_session.php    secure_chat_join.php
    secure_chat_get_members.php       secure_chat_distribute_secret.php
    secure_chat_get_wrapped_secret.php  secure_chat_send.php
    secure_chat_poll.php              secure_chat_unwrap_secure_layer.php
    secure_chat_retire_session.php