This document provides a comprehensive security analysis of PinChat, including the threat model, cryptographic specifications, and security guarantees.
Protocol version: 1 (see PROTOCOL.md). Changes in v1 relative to the implicit v0:
- All Double Ratchet DH public keys are now ECDSA-signed by the peer's identity key; a live MITM cannot swap DH keys mid-session.
- WebSocket authentication uses
Sec-WebSocket-Protocolsubprotocol negotiation; JWT never appears in URLs or logs. - Bootstrap key is zeroed after handshake and re-extracted from the URL fragment on reconnect (was retained indefinitely in memory).
SIGNATURE_INVALIDis a hard session abort: WebSocket is closed with 1008 Policy Violation and auto-reconnect is suppressed.
- Threat Model
- Cryptographic Primitives
- Security Properties
- Attack Surface Analysis
- Client-Side Integrity Verification
- Responsible Disclosure
- Cryptographic Audit Status
PinChat operates under the following trust model:
| Entity | Trust Level | Rationale |
|---|---|---|
| Client Device | Trusted | User's browser must be uncompromised |
| WebCrypto API | Trusted | Browser-native cryptographic implementation |
| Server | Untrusted | Designed to operate as blind relay |
| Network | Untrusted | All traffic assumed interceptable |
| Other Participants | Partially Trusted | Can see decrypted content within their room |
We consider adversaries with the following capabilities:
- Can observe all network traffic
- Can record ciphertext for future analysis
- Cannot modify traffic in transit
Mitigation: TLS 1.3 transport encryption + E2E encryption renders captured traffic undecryptable.
- Can intercept and modify network traffic
- Can attempt to substitute cryptographic keys
Mitigation:
- TLS certificate validation at transport layer
- Identity key signatures on ephemeral keys
- SAS verification for out-of-band confirmation
- Full control over server infrastructure
- Can modify server code
- Can log all incoming/outgoing data
Mitigation:
- All encryption occurs client-side
- Keys never transmitted to server (URL fragment)
- Server operates as blind relay by design
- Attacker gains full server access
- Can extract all data from memory
- Can serve malicious JavaScript
Mitigation:
- No keys stored server-side (PFS)
- No message content accessible
- Subresource Integrity (SRI) enforced on all static assets
- Browser extension verifies SRI attributes match signed manifest
- CSP prevents inline script injection
The following attacks are explicitly not addressed:
- Compromised Client Device: Malware, keyloggers, or screen capture software on user's device
- Compromised Browser: Malicious browser extensions or modified browser builds
- Side-Channel Attacks: Timing attacks, power analysis on client devices
- Participant Misconduct: Screenshots, recording, or deliberate disclosure
- Traffic Analysis: IP addresses, connection timing, message size patterns
- Rubber-Hose Cryptanalysis: Coercion of participants
The Bootstrap Key is a 256-bit AES key generated client-side when a room is created. It serves as the initial shared secret for the ECDH key exchange.
Distribution:
URL format: https://host/c/{room_id}#key={base64url_encoded_key}
Security Properties:
- Never transmitted to server (URL fragment per RFC 3986)
- Shared out-of-band (copy/paste, messaging app, QR code)
- Used only for initial handshake encryption
- Moved out of the URL bar and into tab-scoped
sessionStorageimmediately after the first import (see Lifecycle step 6 below), so the secret stops appearing in browser history, screen-shares, or any same-origin code readingwindow.location.hash.
Usage:
- Encrypts ECDH public keys during handshake (AES-GCM with AAD)
- AAD binds encrypted key to room ID, sender ID, timestamp, and nonce
- After Double Ratchet initialization, message encryption uses derived keys
Lifecycle:
1. Room creator generates Bootstrap Key (256-bit, CSPRNG)
2. Key appended to URL fragment
3. URL shared with participants (out-of-band)
4. Each participant extracts key from fragment
5. Key used to encrypt/decrypt ECDH handshake
6. After successful import, the fragment bytes are stashed in
sessionStorage (key: `pinchat_hash:/static/chat.html`) and removed
from window.location.hash via history.replaceState. Reload survives.
7. Double Ratchet takes over for message encryption
8. cryptoManager.deleteBootstrapKey() nulls the in-memory CryptoKey
reference after handshake completion. resetToBootstrapKey() re-reads
the stash on reconnect / handshake retry.
ECDSA P-256 keypairs that authenticate each Double Ratchet DH header
are persisted client-side in IndexedDB under the database name
pinchat_identity_v1 (object store keys, single entry under key
identity). Entries carry a version field and an absolute expiresAt
timestamp (24 h from creation, aligned with the default session_ttl_secs).
Properties:
- The private side is created
extractable: falsedirectly fromcrypto.subtle.generateKey— no PKCS#8 round-trip. WebCrypto §13 applies[[extractable]]per side; for asymmetric ECDSA keypairs the public side is always extractable regardless of the parameter, soexportKey('raw', publicKey)for peer exchange and SAS continues to work. The private bytes never become reachable to JS, even momentarily. The pre-v0.2.5 build did this via agenerateKey(true)→exportKey('pkcs8')→importKey(..., false)round-trip with a best-effortfill(0)on the buffer; that pattern briefly placed the raw private key bytes in the JS heap, and is gone as of v0.2.5 (finding F-02). - IndexedDB structured-clone (W3C IndexedDB §6 + WebCrypto §13)
preserves
[[extractable]], so restored CryptoKey handles remain non-extractable across page loads. - The public side stays extractable so it can be serialised to raw bytes for transmission to the peer.
- Scope: per-origin, never synced, never sent to the server.
- Expiry: 24 h, after which
generateIdentityKeypair()discards the stale entry and mints a fresh keypair. - Forget gesture:
IdentityKeyManager.clearStoredIdentity()for an explicit "forget me on this device" reset.
The reason this entry is persisted at all is SAS continuity: pre-C-04 the identity keypair was regenerated on every page load, which meant the SAS code that a user had verified out-of-band stopped matching the next time they reopened the chat. The pressure to skip verification followed mechanically. Persistence keeps the SAS stable so verification becomes a one-time gesture per device-day.
Algorithm: AES-GCM (Galois/Counter Mode)
- Key Size: 256 bits
- IV Size: 96 bits (12 bytes)
- Tag Size: 128 bits (16 bytes)
- Mode: Authenticated Encryption with Associated Data (AEAD)
Usage:
- Message encryption
- Image/media encryption
- Handshake key encryption
Security Properties:
- Confidentiality: Plaintext hidden from adversary
- Integrity: Tampering detected via authentication tag
- Authenticity: AAD binds ciphertext to context (room ID, sender ID, message number)
IV Generation:
IV = crypto.getRandomValues(12 bytes)
IVs are generated using CSPRNG and never reused within a key's lifetime.
Algorithm: ECDH (Elliptic Curve Diffie-Hellman)
- Curve: P-256 (NIST secp256r1)
- Key Size: 256 bits
- Output: 256-bit shared secret
Usage:
- Initial key exchange (handshake)
- DH ratchet key rotation
Security Properties:
- Perfect Forward Secrecy: Ephemeral keys are destroyed after use
- Key Agreement: Both parties derive identical shared secret
Implementation Notes:
- P-256 chosen for WebCrypto API compatibility
- X25519 preferred but not natively supported in browsers
- Keys marked non-extractable after import
Algorithm: ECDSA (Elliptic Curve Digital Signature Algorithm)
- Curve: P-256 (NIST secp256r1)
- Hash: SHA-256
- Signature Size: 64 bytes (DER encoded)
Usage:
- Identity key authentication
- Ephemeral key signing (MITM protection)
Security Properties:
- Non-repudiation: Only private key holder can produce valid signatures
- Integrity: Any modification invalidates signature
Implementation Notes:
- Private key made non-extractable after generation
- Public key remains extractable for peer transmission
Algorithm: HKDF (HMAC-based Key Derivation Function)
- Hash: SHA-256
- Salt: Context-dependent (32 bytes)
- Info: Domain separation strings
RFC Compliance: RFC 5869
Usage:
- Root key derivation from ECDH output
- Chain key derivation for Double Ratchet
- Message key derivation
Domain Separation Labels:
"DoubleRatchet-RootKey" - Root key derivation
"InitiatorToResponder" - Initiator's sending chain
"ResponderToInitiator" - Responder's sending chain
"ChainKey" - Chain ratchet progression
"MessageKey-{N}" - Per-message key derivation
Algorithm: HMAC-SHA256 based KDF chain
- Chain Key Size: 256 bits
- Message Key Size: 256 bits
Operations:
// Message key derivation
MK_n = HMAC-SHA256(CK_n, "MessageKey-" || n)
// Chain progression
CK_{n+1} = HMAC-SHA256(CK_n, "ChainRatchet")
Security Properties:
- One-way: Cannot derive CK_n from CK_{n+1}
- Independence: Compromise of MK_n does not reveal MK_{n+1}
Algorithm: HKDF-SHA256
- Output: 72 bits (12 emoji from a 64-character alphabet — 6 bits per emoji)
- Also displayed as 18 hex characters
Input binding:
IKM = sorted(Identity_PubKey_A_raw || Identity_PubKey_B_raw) // 130 bytes for P-256
salt = roomId || "pinchat-sas-v2" // fixed for the room
info = "SAS-display-v2" // domain separation
Security properties:
- Stable. The SAS is a function of
(IK_A, IK_B, room_id)only. Two honest peers who retain their identity keypair across a reconnect derive the same emoji code on every handshake — no per-handshake nonces or timestamps are mixed into the salt. Identity persistence (IndexedDB, 24 h TTL) keeps both identity keys alive for that window, so a user who verified the code once does not face a different code on the next page load. This eliminates the pre-v0.3.0 problem where the SAS changed every reconnect and users learned to skip. - Domain-separated. The literal
"pinchat-sas-v2"tag in the salt prevents collisions with any other HKDF use of the same identity-key pair (future safety-number computations, alternative display formats, protocol-v3, …). The"SAS-display-v2"info string adds a second layer of context separation in the HKDF expand stage. - Symmetric. Both peers sort the two identity public keys lexicographically before concatenating, so the IKM bytes — and thus the SAS output — are identical regardless of who initiated the handshake.
- Grinding-resistant within a static identity. 72 bits of output
defeats commodity GPU brute force at the timescale of a verification
window. On an RTX-4090-class card SHA-256 throughput is ~5 GH/s,
HKDF-SHA256 with one expand block is ~3 SHA-256 ops per derivation,
so ~1.6 billion SAS candidates per second per GPU. A birthday-style
collision search on a 72-bit space requires ~
2^36derivations per pool, i.e. ~43 seconds per pool * two pools = ~90 s of pure SHA work. This is borderline but assumes the attacker can mint arbitrary identity public keys at the same rate — which they cannot, because forging a SAS match requires also finding ECDSA keypairs whose raw exports hash into the target SAS. ECDSA keypair generation is ~10000× slower than a SHA-256 op on a GPU, so the practical wall time is hours-to-days, not seconds. 96 bits / 16 emoji would push this to "intractable" but the UX cost is significant; 72 bits is the chosen balance.
Why HKDF and not PBKDF2 (rationale, deferred-audit response): PBKDF2 is a password stretcher. It is the correct tool when the input is a low-entropy human-chosen secret and the goal is to make brute force expensive. The SAS inputs are uniformly-random P-256 public keys — high entropy, zero password character — and the goal is deterministic display-byte derivation. HKDF is the keyed-PRF construction designed for exactly this. The pre-v0.3.0 path used PBKDF2 with 100 000 iterations, spending ~30-100 ms per derivation to slow down an attack that could not benefit from iteration count (any attacker who can grind PBKDF2 faster than the user can also grind HKDF faster, and the per-derivation cost differential doesn't tilt the balance in either direction when the search space is the bottleneck). The 100K iterations were paying cost for no security property. v0.3.0 drops them.
The pre-v0.3.0 SAS used PBKDF2-SHA256 with 100K iterations, a 48-bit
output (8 emoji), and a salt that incorporated roomId || sorted_nonces || sorted_timestamps. The per-handshake nonces and timestamps made the
SAS change on every reconnect even when both peers retained the same
identity keypair, which trained users to skip verification — exactly
the failure mode the SAS is supposed to prevent. v0.3.0 dropped the
per-handshake material, widened to 72 bits, and switched to HKDF (see
above). Pre-v0.3.0 clients still ship the old construction; mixed-version
chats will display different codes on each side until both endpoints
update.
Definition: Compromise of long-term secrets does not compromise past session keys.
Implementation:
- Ephemeral ECDH keys generated per session
- Chain ratchet derives unique key per message
- Message keys deleted immediately after use
- Chain keys ratcheted forward (one-way)
Guarantee: An attacker who captures ciphertext and later obtains all current keys cannot decrypt historical messages.
Definition: System automatically recovers security properties after key compromise.
Implementation:
- DH ratchet triggered on communication direction change
- New ECDH keypair generated
- New root key derived from fresh DH output
- Both chains re-initialized
Guarantee: If an attacker compromises the current session state, security is restored after the next DH ratchet.
Server Guarantees:
| Property | Guarantee |
|---|---|
| Message Content | Never accessible (client-side encryption) |
| Encryption Keys | Never transmitted (URL fragment) |
| User Identity | Randomized per-connection UUID |
| Cross-Room Correlation | Impossible (fresh UUID per room) |
| Historical Data | None (RAM-only storage) |
AAD (Additional Authenticated Data) Binding:
AAD = TLV_encode([
ROOM_ID,
SENDER_ID,
MESSAGE_NUMBER,
MESSAGE_TYPE,
RATCHET_COUNT
])
Protection Against:
- Cross-room replay attacks
- Message reordering attacks
- Sender impersonation
After a message key or chain key is used, PinChat calls Uint8Array.fill(0) on the raw key material. This is a best-effort mitigation: JavaScript engines (V8, SpiderMonkey) do not guarantee that the backing buffer is zeroed in native memory or that the GC reclaims it promptly. A memory dump of the browser process may still recover key material.
What does help:
- Identity private keys are imported with
extractable: false— WebCrypto's opaqueCryptoKeyobjects cannot be exported or read by JS, even with a memory dump viaexportKey. - Double Ratchet chain keys are zeroized (
fill(0)) after ratchet progression; the old material is no longer reachable from JS once the typed array reference is released.
Recommendation: Use incognito/private mode for sensitive chats. Close the tab immediately after the conversation ends. Do not use PinChat on shared or potentially compromised devices.
The server maintains a per-room set of SHA-256 hashes of received encrypted payloads (bounded at REPLAY_CACHE_MAX_PER_ROOM, default 1 000 entries — see src/config.rs). If a ciphertext is resent verbatim, the server drops it.
Limitation: AES-GCM with a random 96-bit IV produces a different ciphertext for every encryption of the same plaintext, so identical ciphertexts are already an extremely strong indicator of a replay attack. The authoritative replay protection is the Double Ratchet's monotone message counter n (checked client-side against the AAD). The server-side hash check is a defense-in-depth layer that complements, but does not replace, the cryptographic guarantees of the protocol.
Implication: A server memory dump reveals whether a given ciphertext has been seen in a room, but not its plaintext. This is a metadata observation, not a confidentiality breach.
Short Authentication String (SAS) verification is the mechanism by which users confirm that no man-in-the-middle has replaced their peer's ECDH identity key during the handshake. When both participants compare and confirm the emoji codes match, the session is authenticated end-to-end.
If SAS is skipped: the chat is encrypted, but not authenticated against the server operator. The server (or anyone with full relay access) could have substituted both parties' identity public keys with its own at the ECDH exchange step, establishing a session it can decrypt. The ECDSA signatures on the DH ratchet keys only prove self-consistency of those keys — they do not prove ownership by the expected human peer. Only SAS ties the cryptographic identity to a real-world identity.
Current UX: skipping SAS sets sasVerificationStatus = 'skipped' and displays a persistent "Key verification skipped — connection security not confirmed" indicator. Sending and receiving messages remains possible. This is a deliberate usability trade-off: requiring SAS for all chats would add significant friction, and users can make an informed choice.
Recommendation: Always complete SAS verification for sensitive conversations, especially with new contacts. Never skip SAS if you received the room link from an untrusted channel.
Claim matrix. Different user actions place the session in different security states. The headline "end-to-end encrypted" claim does not survive uniformly across all of them:
| Configuration | Effective property | What can the relay see / do? |
|---|---|---|
| SAS verified + integrity extension installed | Double-Ratchet AEAD over an SRI-checked client. Peer identity confirmed out of band. No external crypto audit. | Relay sees ciphertext only. JavaScript tampering requires defeating SRI + signed manifest. |
| SAS verified, no integrity extension | Double-Ratchet AEAD. Peer identity confirmed out of band. | Relay sees ciphertext only, but can serve modified JS on the next page load and read everything from that moment forward — undetected. |
| SAS skipped | Double-Ratchet AEAD — encryption is still active. Peer identity has not been confirmed. | Relay can mount an active MITM at handshake time by substituting identity keys for both peers, establish two ratchets it owns, and read/modify everything in plaintext. |
The phrase "the server cannot read your messages" is only true in the first two rows. In the third row the chat is encrypted but not authenticated against an active server operator. Avoid promising the absolute version of the claim in user-facing copy.
The bootstrap key is normally kept only in the URL fragment (#key=<base64url>), which browsers do not include in HTTP requests (RFC 3986 §3.5), keeping it server-blind. However, if an authenticated session expires mid-navigation (HTTP 401), PinChat stores the fragment in sessionStorage so it can be restored after login completes:
sessionStorage["pinchat_hash:/c/<room_id>"] = "#key=<base64url_key>"
Exposure windows — there are two paths that put the bootstrap key into sessionStorage:
-
Login-bounce path (
static/js/login-stash.js): triggered when an unauthenticated user clicks an invite link and the server redirects to/login. The fragment is moved tosessionStorageso it can be restored after the login round-trip. A 5-minutesetTimeoutsafety net fires on/loginto clear an abandoned stash; on successful post-login restore the chat page consumes and overwrites the stash. -
Direct path (
static/js/crypto.jsextractKeyFromURL): on a normal authenticated invite-link open, the fragment is read once, used to import a non-extractableCryptoKey, then written back tosessionStoragewhile the URL bar is scrubbed viahistory.replaceState. The stash lives for the lifetime of the tab. This is an accepted trade-off: the stash is required bycopyLink()(v0.2.4) to reconstruct the shareable URL after the URL bar scrub, and byresetToBootstrapKey()for handshake-retry resilience.sessionStorageis tab-scoped and auto-cleared by the browser on tab close.
Risk: any same-origin JavaScript executing during the exposure window (e.g., an XSS that bypassed the strict CSP, or a hostile browser extension running in the page) can read the raw key bytes from sessionStorage. The bootstrap key derives only the initial ECDH handshake AEAD — not message content (which is protected by the Double Ratchet chains established after the handshake). However, capture of the bootstrap key plus the initial handshake ciphertext allows recovery of the ECDH public keys and from there the session key; the SAS verification step is the only defense against that recovery in the absence of identity-key persistence.
Mitigations in place:
sessionStorageis scoped to the tab (not shared across tabs or persisted after the tab closes)- Imported
CryptoKeyis non-extractable; only the raw fragment insessionStorageis plaintext-readable - Strict CSP with no inline scripts and SRI on every external script (
hashes.json.signed) bounds the XSS surface sessionStorage.removeItemis called immediately on post-login key restoration (login-bounce path)- A 5-minute
setTimeouton the login-bounce path clears an abandoned/loginstash - The direct-path stash is not actively scrubbed during the session: this is a deliberate trade-off documented above
| Component | Protection | Attack Vector | Mitigation |
|---|---|---|---|
| TLS 1.3 | Transport encryption | Downgrade attacks | HSTS header (1 year) |
| Certificates | Server authentication | Certificate spoofing | Certificate pinning (recommended) |
| WebSocket | Persistent connection | Connection hijacking | JWT token authentication |
| Component | Protection | Attack Vector | Mitigation |
|---|---|---|---|
| CSP | Script injection | XSS attacks | Strict CSP (no inline scripts) |
| X-Frame-Options | Clickjacking | UI redressing | DENY policy |
| Cookies | Session hijacking | CSRF | HttpOnly, Secure, SameSite=Strict |
| Rate Limiting | DoS protection | Resource exhaustion | IP-based + PoW |
| Component | Protection | Attack Vector | Mitigation |
|---|---|---|---|
| Bootstrap Key | Key exchange | Key interception | URL fragment (never sent to server) |
| Identity Keys | MITM attacks | Key substitution | ECDSA signatures + SAS verification |
| Session Keys | Message confidentiality | Key compromise | Double Ratchet (PFS + PCS) |
| IV/Nonce | Encryption safety | Nonce reuse | CSPRNG, unique per message |
| Component | Protection | Attack Vector | Mitigation |
|---|---|---|---|
| Memory | Data persistence | Memory dumps | No keys stored, auto-cleanup |
| Logs | User privacy | Log analysis | PRIVACY_MODE=strict (zero logs) |
| IP Addresses | User tracking | IP logging | HMAC-hashed IPs in rate limiter |
Even with end-to-end encryption, web applications face a fundamental trust issue: users must trust that the server delivers unmodified JavaScript code. A compromised server could serve malicious code that:
- Exfiltrates encryption keys before they're used
- Sends plaintext to a third party before encryption
- Weakens cryptographic parameters
- Bypasses SAS verification
This is known as the "JavaScript delivery problem" and affects all web-based E2E encryption systems.
PinChat provides browser extensions for Chrome and Firefox that verify file integrity using Subresource Integrity (SRI) combined with signed manifests.
The previous approach (extension fetches files separately and computes hashes) was vulnerable to bypass attacks: a sophisticated attacker could detect extension requests (via headers, timing) and serve clean files to the extension while serving malicious code to the browser.
With SRI:
- HTML files contain hardcoded
integrity="sha256-..."attributes - Browser natively refuses to execute any JS/CSS that doesn't match the hash
- Extension verifies the actual DOM contains correct integrity attributes
- Manifest is signed and hosted on GitHub (out of server's control)
┌─────────────────────────────────────────────────────────────────────────┐
│ SRI-BASED INTEGRITY VERIFICATION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ GitHub Repository pinchat.io Server │
│ (Out-of-band source) (Potentially compromised) │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │hashes.json.signed│ │ HTML with SRI attributes │ │
│ │ ┌──────────────┐ │ │ <script src="app.js" │ │
│ │ │ file hashes │ │ │ integrity="sha256-ABC..."> │ │
│ │ │ + signature │ │ └──────────────┬───────────────┘ │
│ │ └──────────────┘ │ │ │
│ └────────┬─────────┘ │ │
│ │ │ │
│ │ 1. Fetch signed │ 2. User visits │
│ │ manifest │ page │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ BROWSER EXTENSION │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │ │
│ │ │ 2. Verify │ │ 3. Content │ │ 4. Compare │ │ │
│ │ │ ECDSA │──▶│ script reads │───▶│ DOM SRI │ │ │
│ │ │ Signature │ │ actual DOM │ │ vs │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ Manifest │ │ │
│ │ └──────┬──────┘ │ │
│ └────────────────────────────────────────────────────────┼────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ BROWSER ENGINE │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ 5. SRI Enforcement: Browser blocks any JS/CSS where │ │ │
│ │ │ file hash ≠ integrity attribute hash │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┬──────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ✓ VERIFIED │ │ ⚠ SRI MISSING │ │ ⚠ SRI MISMATCH │ │
│ │ All checks │ │ Extension │ │ Extension │ │
│ │ passed │ │ warns user │ │ warns user │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
| Component | Algorithm | Purpose |
|---|---|---|
| Hash Algorithm | SHA-256 | File integrity verification |
| Signature Algorithm | ECDSA P-256 | Hash list authentication |
| Key Distribution | Embedded in extension | Trust anchor for signature verification |
What This Protects Against:
- Server compromise serving malicious JavaScript
- CDN/proxy tampering with static files
- DNS hijacking serving fake content
- Supply chain attacks on deployment
- Bypass attacks where server detects extension and serves clean files to it
What This Does NOT Protect Against:
- Compromised signing key (attacker could sign malicious hashes)
- Malicious browser extension updates
- Users ignoring warning overlays
- Dynamic content manipulation (API responses)
- First-page-load without extension (user must install extension first)
Trust Chain:
1. User trusts the extension (installed from browser store or self-hosted)
2. Extension contains hardcoded ECDSA public key
3. Hash list signed with corresponding private key
4. Private key held securely by project maintainer
5. Any file modification breaks the signature chain
Key Management:
- Private key: Stored securely, never committed to repository
- Public key: Embedded in extension source code
- Key rotation: Requires extension update
The extension uses a dual verification approach:
- Fetch Manifest: Extension retrieves
hashes.json.signedfrom GitHub - Verify Signature: ECDSA P-256 signature validated using embedded public key
- DOM SRI Check: Content script reads
<script>and<link>elements, verifiesintegrityattributes match signed manifest - File Hash Verification: Fetches ALL files listed in manifest (not just DOM) and computes SHA-256 hashes
- Detect Unauthorized Resources: Inline scripts, external resources, same-origin scripts outside
/static/, iframes, external forms - Browser Enforcement: Browser independently blocks any file not matching its SRI hash
- Alert: Visual feedback (badge + overlay) based on verification result
This dual approach catches:
- Lazy-loaded or deferred scripts not yet in DOM
- Server serving modified files (even with correct SRI in HTML)
- Files blocked by browser SRI (hash mismatch detected independently)
| Layer | Protection | Bypassed by |
|---|---|---|
| Browser SRI | Blocks tampered files | N/A (native browser security) |
| Extension SRI check | Detects missing/wrong integrity | User ignoring warnings |
| Extension hash verification | Detects file tampering (all manifest files) | User ignoring warnings |
| Manifest signature | Authenticates hash list | Key compromise |
| Out-of-band manifest | Server can't forge hashes | GitHub compromise |
| Anti-downgrade sequence | Prevents replay of old manifests | Storage cleared + old manifest |
| Condition | Badge | Action |
|---|---|---|
| All checks pass | ✓ Green | Safe to proceed |
| Manifest signature invalid | ! Red | Full-screen warning overlay |
| Manifest downgrade detected | ! Red | Full-screen warning overlay |
| SRI attribute missing | ! Red | Full-screen warning overlay |
| SRI mismatch with manifest | ! Red | Full-screen warning overlay |
| File hash mismatch | ! Red | Full-screen warning overlay |
| Inline script detected | ! Red | Full-screen warning overlay |
| External resource detected | ! Red | Full-screen warning overlay |
| Unauthorized same-origin script | ! Red | Full-screen warning overlay |
| Network error fetching manifest | ? Yellow | Retry, inform user |
| GitHub unavailable | ? Yellow | Cached result or warning |
| File hash ≠ SRI attribute | N/A | Browser blocks the file |
- First-Use Trust: User must trust the initial extension installation
- Update Window: Between file changes and hash list update, verification may fail
- Key Compromise: If signing key is compromised, protection is void
- User Override: Determined users can dismiss warnings
- Extension Required: Browser SRI protects against file tampering, but without extension, HTML could be modified to remove/change SRI
For maximum security:
- Install extensions from source code review, not pre-built packages
- Verify the embedded public key matches the project's published key
- Never dismiss integrity warnings without investigation
- Report any unexpected verification failures
If you discover a security vulnerability in PinChat, please report it responsibly:
- Do NOT disclose the vulnerability publicly before it is fixed
- Do NOT exploit the vulnerability beyond what is necessary to demonstrate it
- Do provide sufficient detail for us to reproduce and fix the issue
Report vulnerabilities via:
- GitHub Security Advisories (preferred)
- Email: security@pinchat.io
- Description of the vulnerability
- Steps to reproduce
- Potential impact assessment
- Suggested remediation (if any)
| Phase | Timeline |
|---|---|
| Initial Response | 48 hours |
| Vulnerability Confirmation | 7 days |
| Fix Development | Varies by severity |
| Public Disclosure | After fix deployed + 30 days |
| Severity | Criteria | Example |
|---|---|---|
| Critical | Remote code execution, key compromise | Server-side key extraction |
| High | Authentication bypass, data exposure | Session hijacking |
| Medium | Limited data exposure, DoS | Rate limit bypass |
| Low | Information disclosure, minor issues | Verbose error messages |
Security researchers who responsibly disclose valid vulnerabilities will be:
- Credited in release notes (if desired)
- Listed in security acknowledgments
Current Status: Not formally audited
This implementation follows established protocols (Signal Double Ratchet) and uses standard cryptographic primitives via the WebCrypto API. However, it has not undergone a formal security audit by an independent third party.
Security Features Implemented:
- ✅ Subresource Integrity (SRI) for all static assets
- ✅ Browser extension with SRI verification
- ✅ Signed manifest hosted out-of-band (GitHub)
- ✅ CSP preventing inline script execution
Recommendations before production use:
- Commission a professional cryptographic audit
- Consider certificate pinning for mobile clients
- Establish incident response procedures
- Set up monitoring for manifest signature failures