updates

changelog

major releases and significant changes only — minor fixes are shipped silently

Message delivery failures, remove contact, relay error visibility
fix
message delivery failure detection
If a message fails to reach the relay (no circuit, no relays registered, or WebSocket error), it is now flagged inline with a red border and a "not delivered" label. A retry button re-encrypts and re-sends the message through the ratchet immediately.
fix
relay error toast
If the server returns a relay_error (entry relay unavailable), a visible error toast is shown so the user knows messages may not be going through — rather than silent failure.
new
remove contact
A remove-contact button (person × icon) in the DM chat header opens a confirmation modal. Confirmed removal deletes all local messages, clears the contact from the sidebar, and revokes any cached blob URLs. The removed contact is not notified.
Voice messages, call quality, read receipts, typing indicators, settings overhaul
new
voice messages
Hold the mic button in the chat input to record — releases sends the clip as an encrypted audio file. Rendered inline with a custom play/pause button and live progress bar.
new
call quality indicator & debug panel
A live coloured dot in the call bar tracks WebRTC connection health (green/yellow/red based on RTT and packet loss). Click it to open a debug panel showing RTT, packet loss, jitter, and a live sparkline graph of RTT over the last 2 minutes. Works for both DM and group calls.
new
read receipts
Opt-in encrypted read confirmations — when you open a conversation your contact receives a signed, ratchet-encrypted read_receipt packet listing the message IDs you've seen. Outgoing messages show a single checkmark (sent) or double checkmark (read). Toggle in settings → privacy.
new
typing indicators
Sends a lightweight encrypted typing signal while composing (throttled to once per 2.5 seconds). Recipients see a three-dot bounce animation above the input. Disappears automatically after 3.5 seconds of inactivity.
improvement
settings modal redesigned with tabs
Settings are now split into four tabs — Account, Voice, Privacy, and Danger — reducing vertical scroll and making each section easier to find. Read receipts and store files live under Privacy; noise suppression and PTT under Voice.
improvement
message read status dots on outgoing messages
Every sent message now shows ✓ when delivered and ✓✓ (purple) when the recipient has opened the conversation and read receipts are enabled on their end.
Full-spectrum security hardening

Comprehensive adversarial audit covering network, application, metadata, browser, infrastructure, and cryptographic layers. All fixable issues are addressed in code. Residual infrastructure limitations are documented in the in-app security advisory.

security
sender identity removed from message packets
Previously every encrypted_message packet included a cleartext sender_id field visible to relays and the coordinator. Recipients now identify the sender from the ratchet header's ephemeral DH public key. A ratchet_pub_to_contact map provides O(1) lookup for known contacts; unknown DH keys fall back to O(n) speculative decryption across all contacts. Sender identity is only present inside the AES-256-GCM ciphertext that only the recipient can open.

Before: { type:'encrypted_message', sender_id:'abc123', data:{...} }
After: { type:'encrypted_message', data:{header,iv,ciphertext} }
security
file encryption key moved inside ratchet channel
File transfers previously embedded file_key, name, mime, size, and total in cleartext outer packets, exposing metadata to relays. Now all file metadata travels in a file_start message inside the ratchet-encrypted channel. Outer file_chunk packets carry only routing data and the encrypted chunk payload. Chunks arriving before file_start are queued until the metadata arrives.

Before: file_chunk → { file_key, name, mime, size, total, sender_id, ... }
After: encrypted_message → { type:'file_start', file_key, name, ... } ← ratchet only
file_chunk → { file_id, idx, iv, ciphertext, hash } ← no metadata
security
sender_id removed from group message broadcasts
Group packets (group_msg, group_member_left, group_invite, and all other group types) previously carried a cleartext sender_id. The sender field is now read from the decrypted inner AES-256-GCM payload (payload.from), which is encrypted under the group key that only members hold.
security
IV replay protection on relay nodes
Relay nodes now maintain a seen_ivs set. Any packet whose AES-GCM IV was processed within the last 60 seconds is rejected with a replay error. This blocks replay attacks where an attacker re-submits captured onion packets.

Before: relay re-processes duplicate IV → packet delivered again
After: relay checks seen_ivs → duplicate thrown, not forwarded
security
relay registration restricted to localhost + shared secret
The POST /api/relay/register endpoint previously accepted registrations from any host. It now requires both: (1) the request originates from 127.0.0.1 / ::1, and (2) a RELAY_SECRET that is generated at startup and passed only to forked relay child processes. External relay registration is impossible.
security
/api/deliver restricted to localhost
The delivery endpoint that injects packets into WebSocket sessions was reachable from the public internet. It now checks req.socket.remoteAddress and returns 403 for any non-loopback caller. Only relay processes running on the same machine can deliver packets.
security
direct_deliver WebSocket handler removed
A direct_deliver message type on the WebSocket allowed any connected client to bypass onion routing entirely and inject a packet directly into any other client's session. This handler is removed. All packet delivery now goes through relay nodes only.
security
WebSocket payload size cap
The WebSocket server now enforces a 128 KB maxPayload limit. Oversized frames are rejected before processing, preventing memory exhaustion attacks via malformed giant packets.
security
CSP connect-src locked to same origin
The Content-Security-Policy previously allowed WebSocket connections to any host (ws: wss:). It is now restricted to 'self', preventing exfiltration via injected scripts that open WebSockets to attacker-controlled servers.
security
HSTS, COOP, and COEP headers added
Strict-Transport-Security (1 year, includeSubDomains) forces HTTPS on all future visits. Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp isolate the browsing context, enabling SharedArrayBuffer and mitigating Spectre-style cross-origin leaks.
fix
autoauth namespace fallback was static
autoauth_module.get_ns() fell back to the static string 'd' when no session namespace existed. All users who loaded without a namespace shared the same IndexedDB key prefix, meaning one user's encrypted identity blob could be loaded by another user on the same device. The fallback now generates a fresh crypto.randomUUID() per session.
new
in-app security advisory
A one-per-session modal now surfaces on first load, informing users of limitations that cannot be fixed in code: relay co-location, browser fingerprinting, DNS leaks, WebRTC IP exposure, and cover traffic statistical distinguishability. The advisory does not appear again until the tab is closed and reopened.
One-time refresh didn't notify group members

Wiping an identity manually sent group_member_wiped to all group members correctly. But refreshing a one-time account (beacon-based wipe) only notified DM contacts — group members never received the wipe signal, so the wiped user remained visible in groups with their messages intact.

fix
beacon didn't include group membership
set_onetime_beacon only stored the list of DM contact IDs. Group data was never written into the beacon. On refresh, execute_pending_wipe had no group info to work with so it silently skipped groups entirely. The beacon now also stores each group's ID and its member list, serialised into sessionStorage at the same time as the contact list.
fix
execute_pending_wipe didn't send group wipe notices
Even if group data had been present, execute_pending_wipe never iterated over groups. It now reads the stored group list and sends group_member_wiped to every member of every group the wiped identity belonged to, using the same 3-second race timeout as the DM notices.
fix
beacon not updated when joining or leaving a group
maybe_update_beacon was only called on DM contact events. If a one-time user joined a group after their identity was generated, that group was never written into the beacon. The beacon is now refreshed on group_created, group_joined, group_left, and group_wiped events.
One-time identity expiry highlight + backup key removal

Two residual leaks from previous timed accounts were still surfacing inside one-time sessions — a stale expiry button highlight and an accessible backup key export.

fix
expiry button showing as active on one-time accounts
The settings modal applied expiry_active to whichever expiry button matched the stored key — even for one-time identities where the grid is disabled. If the previous session had selected e.g. never or 1 day, that button appeared highlighted in the new one-time session, creating visual confusion and user concern that the expiry state was bleeding into the ephemeral account. The active class is now suppressed entirely when the account is one-time.
fix
backup key visible and copyable on one-time accounts
The identity modal always exported and displayed the full private key regardless of account type. A one-time identity should never be exportable — persisting it defeats its purpose. The backup key button and textarea are now hidden for one-time accounts, replaced with an explanatory note. The underlying export_identity() function in the app module also throws if called while in one-time mode, so no code path can produce an export key for an ephemeral session.
One-time identity settings lockdown

The settings panel exposed controls for one-time identities that should never apply to them, creating a path to accidentally persist an ephemeral session.

fix
auto login toggle accessible on one-time accounts
The auto login toggle was active for one-time identities. Enabling it called autoauth_module.set_enabled(true) and saved the identity to IndexedDB. On the next page refresh, the identity loaded back — defeating the entire point of a one-time session. The auto login section is now greyed out and fully blocked (both visually and in the click handler) for one-time identities.
fix
missing set_ns when enabling auto login from settings
When enabling auto login from the settings toggle (rather than during identity generation), autoauth_module.set_ns was never called. This meant the identity could be saved under the wrong namespace key, causing the saved data to be unrecoverable or to bleed into a different session's namespace. The namespace is now pinned to the current identity before saving.
Group wipe propagation & one-time identity hardening

Two fixes closing gaps where wiped identities remained visible to other users, and where one-time accounts could be misconfigured.

fix
wiped identity still visible in group on other devices
When a user wiped their identity, DM contacts were correctly notified (via contact_wiped) and removed the user. Group members were never notified — on every other device, the wiped user remained in the member list and all their messages were still visible.

A new group_member_wiped packet is now broadcast to all members of every group before the local state is cleared. On receiving it, other clients immediately remove the member from the group and delete all messages sent by that identity.
before
user wipes → DM contacts notified
group members: no notification
→ wiped user still shown in group
→ their messages remain visible
after
user wipes → DM contacts notified
group members: group_member_wiped sent
→ member removed from group
→ their messages deleted on all clients
fix
expiry settings available for one-time identities
The account expiry controls in settings were active for one-time identities. One-time accounts have no persistence — setting an expiry on them has no effect but could mislead a user into thinking their session has a timer. The expiry grid is now greyed out and disabled for one-time identities with a clear explanation.
Group files, wipe immediacy & onion payload overflow

Five bugs fixed — two of which caused silent data loss, one caused identity data to remain visible after wipe.

fix
group files not showing on receiving end
Three compounding bugs prevented group file delivery.

issue 1 — onion payload length overflow
The content length inside each onion layer was stored as a 16-bit integer (max 65,535 bytes). A file large enough to push the packet over that limit caused a silent overflow — the receiver read a garbage length, sliced the wrong bytes, JSON.parse failed, and the message was dropped with no error.
before
length stored as Uint16 (max 65 KB)
file > ~40 KB → overflow → garbage len
receiver slices wrong bytes
→ JSON.parse fails → message dropped
after
length stored as Uint32 (max 4 GB)
file size no longer causes overflow
receiver slices correct bytes
→ JSON.parse succeeds → file delivered
issue 2 — server body size limit
express.json() defaults to a 100 KB body limit. Larger file packets were silently rejected at /api/deliver — no error reached the sender, the file just never arrived.
before
express.json() limit: 100 KB
file packet > 100 KB → 413 rejected
→ silently dropped, no feedback
after
express.json() limit: 50 MB
file packets pass through
→ delivered to recipient
issue 3 — group state never restored after refresh
import_state() referenced a variable named parsed that was never declared. On every autoauth session restore, a ReferenceError was silently swallowed — contacts loaded fine but group membership and keys were never re-imported. After a page refresh the user appeared to still be in the group but couldn't decrypt any messages.
before
JSON.parse(json_str) destructured inline
parsed.groupsReferenceError
caught silently by outer try/catch
→ groups not restored → decrypt fails
after
const parsed = JSON.parse(json_str)
parsed.groups resolves correctly
groups, keys & messages re-imported
→ session fully restored on refresh
fix
wipe identity left messages & account visible for up to 3 seconds
wipe_identity() sent contact-wiped notices to all DM contacts before emitting the wiped event (which clears the UI). The notice delivery waited up to 3 seconds, so the main view — including all messages, group chats, and identity — remained on screen after the user triggered a wipe. The UI now clears immediately; notices are sent in the background.
fix
group file delete called wrong function
The delete button on file messages in group chats called app_module.delete_message (the DM delete path), passing the group ID as if it were a contact ID. The call failed silently and the message was never removed. The handler now checks has_group(id) and routes to app_module.group.delete_message for group context.
At-rest encryption hardening & relay isolation

Two anonymity fixes addressing weaknesses identified in internal audit.

fix
issue 1 — auto-login key stored alongside ciphertext
Previously the random secret used to encrypt your saved identity was stored in IndexedDB next to the encrypted blob. Anyone with access to the browser's IndexedDB (forensic dump, physical device access) could recover both and decrypt the stored session.

The encryption layer has been replaced with a non-extractable AES-GCM-256 CryptoKey generated via the browser's WebCrypto API and stored directly in IndexedDB as an opaque object. With extractable: false, the raw key bytes can never be read or exported from JavaScript — exportKey() throws, no API surface exposes the material. The ciphertext remains in IDB but without the key bytes alongside it.

before
IDB[_k_] = secret[] (raw bytes)
IDB[_b_] = { salt, iv, ciphertext }
→ dump IDB → decrypt instantly
after
IDB[_k_] = CryptoKey (non-extractable)
IDB[_b_] = { iv, ciphertext }
→ dump IDB → key bytes are opaque
Note: existing auto-login sessions will require one re-login after this update.
fix
issue 2 — relay nodes hardcoded to localhost
All three relay nodes were spawned as child processes on the same machine and communicated via ws://localhost:3001/2/3. The server operator could trivially observe all relay hops because they share the same process space — the multi-hop routing only protected against external observers, not the server itself.

Relay nodes now register and forward using a configurable RELAY_URL environment variable. Packets carry the next relay's full URL rather than a localhost port. Relay nodes can now run on completely separate VPS instances — each hop is an independent server that only knows its immediate predecessor and successor.

before
all relays: localhost:3001/2/3
same machine → same operator
→ server sees all hops
after
relay 1: relay1.yourdomain.com
relay 2: relay2.yourdomain.com
relay 3: relay3.yourdomain.com
→ no single node sees full path
To deploy: run relay/node.js on separate VPS instances with RELAY_URL=ws://<external-ip>:PORT and COORDINATOR_URL=https://nullcord.xyz.
multi-account isolation, offline request delivery, file persistence
  • fixed: multiple accounts in separate tabs no longer bleed contacts or state into each other — each identity is now namespaced by its own ID in isolated storage
  • fixed: your own identity can no longer appear as a contact regardless of stale state
  • contact and group requests are now queued server-side when you are offline and delivered on reconnect — max 24 hour hold, auto-expired after that
  • groups (messages, members, encryption key) now persist across refresh for timed identities alongside DM contacts
  • files and images now persist across refresh when "store files & media" is enabled in settings — stored locally, encrypted, zero server knowledge
security audit — headers, dead code

security audit — two issues found and resolved.

issue 1 — missing security headers (medium)
server sent no Content-Security-Policy, Referrer-Policy, or framing headers — leaving the app open to script injection and referrer leakage.
── before ──
HTTP response headers: (none)
▲ injected scripts could load external resources
▲ referrer header could leak app URL to third parties
▲ app could be embedded in iframe (clickjacking)
── after ──
Content-Security-Policy: script-src 'self'
✓ only scripts from this server can run
connect-src 'self' ws: wss:
✓ no external HTTP/WebSocket connections allowed
img-src 'self' data: blob:
✓ no external image fetches (tracking pixels blocked)
Referrer-Policy: no-referrer
✓ referrer header stripped on all requests
X-Frame-Options: DENY
✓ cannot be embedded in external frames
issue 2 — dead image-rendering code (low)
unused is_image_url and build_link_img_el functions remained in the codebase after their call sites were removed — any future re-connection of these would re-introduce external image fetching. removed entirely.
identity licences, session persistence, security patch
  • one-time licence — fully ephemeral session, contacts and messages are never written to disk, wipe notices sent to all contacts on refresh or tab close
  • timed licence — contacts and messages persist across refresh using AES-GCM-256 encrypted IndexedDB storage, auto-destroyed when the chosen expiry elapses
  • loading screen shown during auto-login so the auth card never flashes mid-session
  • logout button added — returns to home without destroying your identity or autoauth settings
  • terms of service and changelog pages added
  • security: removed automatic external image rendering — inline URLs are no longer fetched as images, preventing tracking-pixel and IP-leak attacks via crafted message links
groups, calls, screen sharing
  • encrypted group messaging with per-group AES-GCM-256 symmetric keys
  • group invites — direct invite or shareable invite links
  • anyone in a group can remove members, rename the group, or delete messages
  • group expiry — auto-wipe after a set time
  • group voice calls over mesh WebRTC
  • group screen sharing with live participant tile overlay
  • key rotation on member removal
voice calls, screen sharing, file transfer
  • end-to-end encrypted voice calls over WebRTC
  • screen sharing with inline viewer — no new window required
  • encrypted file transfer with chunk-based delivery
  • identity rotation — generate new keys and notify contacts, untraceable from old identity
  • auto-login — encrypted local key storage, rotated every session
  • configurable account expiry (1h / 1d / 1w / 1mo / never)
initial release — core messaging
  • anonymous identity generation — no accounts, no sign-up
  • double-ratchet E2E encrypted direct messaging
  • onion-routed message delivery
  • contact requests with accept / reject flow
  • message deletion — either party can remove any message
  • identity self-destruct — wipe all data and notify contacts