security: fix SAS verification bypass and unauthenticated frame injection; release v5.5.2
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

A security review of the transport and verification layers. Every item is a fix
to how untrusted peer input is handled; no features changed.

- SAS verification could be bypassed. `verification_both_confirmed` is an
  unauthenticated frame on a channel that is not yet trusted, but it was taken as
  proof that both sides had compared their codes — so a peer who completed the
  signalling exchange could send it right after the data channel opened and drive
  the other side to a "verified" session while the user never looked at the code.
  It is now only an acknowledgement: refused unless this side already confirmed
  locally, and _setVerifiedStatus() independently rejects any SAS-based
  transition without a local confirmation. Holding ECDH-derived keys was never
  proof of identity — a MITM has those too.

- Unauthenticated frames could be injected into the chat. A bare
  {type:"message"} frame, a raw non-JSON frame and a binary frame were each
  decoded and rendered, bypassing decryption, the HMAC check and the verification
  gate; the injected text was indistinguishable from a genuine message. Chat
  content now reaches the UI only through the authenticated enhanced_message
  path.

- A peer could supply the verification code. `sas_code` announcements were
  adopted verbatim when no local SAS had been derived yet. They may now only
  corroborate the locally derived code.

- Anti-replay never ran. The sequence-number and AAD validators were defined on
  SecureKeyStorage instead of the connection manager, so every call site failed
  with a TypeError and the sliding replay window was dead code. Moved onto the
  manager, wired into the live chat path, and a missing or non-numeric sequence
  number now fails closed instead of sailing through the range checks.

- File transfers are gated on verification in both directions. Control frames are
  written straight to the data channel by the transfer system; sending was
  already gated, receiving now is too.

- Tighter CSP: connect-src and img-src no longer allow arbitrary https: hosts
  (nothing in the app talks to a third party), plus base-uri 'none'.

- The SAS is no longer written to logs, and is compared in constant time on every
  path. Fixed SecureMasterKeyManager.isUnlocked() testing a field renamed long
  ago, so it never actually gated anything.

- Fixed the header showing "Secure undefined%": getRealSecurityLevel() became
  reachable for the first time by the move above and returned only per-feature
  booleans, while the header renders `level` and `score` directly. It now runs
  the same verified scoring as every other consumer.

Adds regression tests for the verification gate, inbound frame authentication and
the security-level shape.
This commit is contained in:
lockbitchat
2026-07-27 00:10:29 -04:00
parent b3fcf54670
commit 6152a77b51
18 changed files with 1184 additions and 521 deletions
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## v5.5.2 — Fix security level in the header
### Fixed
- **Header showed "Secure undefined%".** Moving `getRealSecurityLevel()` onto the connection manager in v5.5.1 made it reachable for the first time, so the header started calling it instead of falling through to `calculateAndReportSecurityLevel()`. It returned only per-feature booleans — no `level`, no `score` — and the header renders those two fields directly. It now runs the same verified scoring as every other consumer and merges the feature flags on top, so all callers see one consistent number. A regression test pins the shape for every branch of the header's fallback chain.
## v5.5.1 — Security audit fixes
A security review of the transport and verification layers. Every item below is a
fix to how untrusted peer input is handled; no features changed.
### Security
- **SAS verification can no longer be bypassed.** `verification_both_confirmed` is an unauthenticated frame that arrives on a channel that is not yet trusted, but it was accepted as proof that both sides had compared their codes — so a peer who completed the signalling exchange could send it right after the data channel opened and drive the other side to a "verified" session while the user never looked at the code. It is now only an *acknowledgement*: it is refused unless this side has already confirmed locally, and `_setVerifiedStatus()` independently rejects any SAS-based transition without a local confirmation. Holding ECDH-derived keys was never sufficient proof of identity — a MITM has those too.
- **Unauthenticated frames can no longer be injected into the chat.** A bare `{type:"message"}` JSON frame, a raw non-JSON text frame, and a binary frame were each decoded and rendered in the chat, bypassing decryption, the HMAC check and the verification gate — the injected text was visually indistinguishable from a genuine message. Chat content now reaches the UI only through the authenticated `enhanced_message` path; everything else is dropped and logged.
- **A peer can no longer supply the verification code.** `sas_code` announcements were adopted verbatim when no local SAS had been derived yet, which would have shown the user a number chosen by the other end. The announcement may now only corroborate the locally derived code; a missing or mismatching code aborts the session.
- **File transfers are gated on verification in both directions.** File control frames (`file_transfer_start`, `file_chunk`, …) are written straight to the data channel by the transfer system, so they never passed through the send path's verification gate on receipt. Sending was already gated; receiving now is too, so an unverified peer cannot open transfers, push chunks or drive transfer state before the SAS has been compared. User consent remains required on top of this.
- **Anti-replay is actually enforced.** The sequence-number and AAD validators were defined on the wrong class (`SecureKeyStorage` instead of the connection manager), so every call site silently failed and the sliding replay window never ran. They now live on the manager, the live chat path validates the authenticated sequence number of each message, and a missing or non-numeric sequence number fails closed instead of sailing through the range checks.
- **Stale sequence numbers are rejected** rather than logged and decrypted anyway.
- **Tighter CSP.** `connect-src` and `img-src` no longer allow arbitrary `https:` hosts (nothing in the app talks to a third party), and `base-uri 'none'` is set. This removes the exfiltration channel an injection would otherwise have.
- The SAS is no longer written to logs, and the peer-announced code is compared in constant time on every path.
- Fixed `SecureMasterKeyManager.isUnlocked()` testing a field renamed long ago, so it never actually gated anything.
### Added
- Regression tests covering the verification gate and inbound frame authentication (`tests/verification-gate.test.mjs`, `tests/inbound-frame-authentication.test.mjs`).
## v5.5.0 — Encrypted voice & video calls
SecureBit now supports **end-to-end encrypted voice and video calls** — the "5.5 Secure Voice & Calls" roadmap milestone.
+1 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-5.5.0-3ecf8e.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-5.5.2-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
+270 -205
View File
@@ -3991,15 +3991,16 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils {
}
const messageAge = Date.now() - metadata.timestamp;
if (messageAge > 18e5) {
throw new Error("Message expired (older than 5 minutes)");
throw new Error("Message expired (older than 30 minutes)");
}
if (expectedSequenceNumber !== null) {
if (metadata.sequenceNumber < expectedSequenceNumber) {
_EnhancedSecureCryptoUtils.secureLog.log("warn", "Received message with lower sequence number, possible queued message", {
_EnhancedSecureCryptoUtils.secureLog.log("error", "Rejected message with stale sequence number - possible replay", {
expected: expectedSequenceNumber,
received: metadata.sequenceNumber,
messageId: metadata.id
});
throw new Error(`Stale sequence number: expected at least ${expectedSequenceNumber}, got ${metadata.sequenceNumber}`);
} else if (metadata.sequenceNumber > expectedSequenceNumber + 10) {
throw new Error(`Sequence number gap too large: expected around ${expectedSequenceNumber}, got ${metadata.sequenceNumber}`);
}
@@ -9676,6 +9677,14 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
if (!verificationMethod || verificationMethod === "unknown") {
throw new Error("Cannot set verified=true without specifying verification method");
}
if (verificationMethod.includes("SAS") && !this.localVerificationConfirmed) {
this._secureLog("error", "Blocked verified transition without local SAS confirmation", {
verificationMethod,
localConfirmed: this.localVerificationConfirmed,
remoteConfirmed: this.remoteVerificationConfirmed
});
throw new Error("Cannot set verified=true without local SAS confirmation");
}
this._secureLog("info", "Connection verified through cryptographic verification", {
verificationMethod,
hasEncryptionKey: !!this.encryptionKey,
@@ -9732,6 +9741,208 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
throw new Error(`AAD validation failed: ${error.message}`);
}
}
// ============================================
// ANTI-REPLAY / SEQUENCE VALIDATION
// These belong to the connection, not to key storage: they read and mutate
// this.replayWindow / this.expectedSequenceNumber / this.currentSession.
// ============================================
// Method _generateNextSequenceNumber moved to constructor area for early availability
/**
* Validate incoming message sequence number
* This prevents replay attacks and ensures message ordering
*/
_validateIncomingSequenceNumber(receivedSeq, context = "unknown") {
try {
if (!this.replayProtectionEnabled) {
return true;
}
if (typeof receivedSeq !== "number" || !Number.isFinite(receivedSeq)) {
this._secureLog("warn", "Missing or non-numeric sequence number - rejecting", {
receivedType: typeof receivedSeq,
context,
timestamp: Date.now()
});
return false;
}
if (receivedSeq < this.expectedSequenceNumber - this.replayWindowSize) {
this._secureLog("warn", "Sequence number too old - possible replay attack", {
received: receivedSeq,
expected: this.expectedSequenceNumber,
context,
timestamp: Date.now()
});
return false;
}
if (receivedSeq > this.expectedSequenceNumber + this.maxSequenceGap) {
this._secureLog("warn", "Sequence number gap too large - possible DoS attack", {
received: receivedSeq,
expected: this.expectedSequenceNumber,
gap: receivedSeq - this.expectedSequenceNumber,
context,
timestamp: Date.now()
});
return false;
}
if (this.replayWindow.has(receivedSeq)) {
this._secureLog("warn", "Duplicate sequence number detected - replay attack", {
received: receivedSeq,
context,
timestamp: Date.now()
});
return false;
}
this.replayWindow.add(receivedSeq);
if (this.replayWindow.size > this.replayWindowSize) {
const oldestSeq = Math.min(...this.replayWindow);
this.replayWindow.delete(oldestSeq);
}
if (receivedSeq === this.expectedSequenceNumber) {
this.expectedSequenceNumber++;
while (this.replayWindow.has(this.expectedSequenceNumber - this.replayWindowSize - 1)) {
this.replayWindow.delete(this.expectedSequenceNumber - this.replayWindowSize - 1);
}
}
this._secureLog("debug", "Sequence number validation successful", {
received: receivedSeq,
expected: this.expectedSequenceNumber,
context,
timestamp: Date.now()
});
return true;
} catch (error) {
this._secureLog("error", "Sequence number validation failed", {
error: error.message,
context,
timestamp: Date.now()
});
return false;
}
}
// Method _createMessageAAD moved to constructor area for early availability
/**
* Validate message AAD with sequence number
* This ensures message integrity and prevents replay attacks
*/
_validateMessageAAD(aadString, expectedMessageType = null) {
try {
const aad = JSON.parse(aadString);
if (aad.sessionId !== (this.currentSession?.sessionId || "unknown")) {
throw new Error("AAD sessionId mismatch - possible replay attack");
}
if (aad.keyFingerprint !== (this.keyFingerprint || "unknown")) {
throw new Error("AAD keyFingerprint mismatch - possible key substitution attack");
}
if (!this._validateIncomingSequenceNumber(aad.sequenceNumber, aad.messageType)) {
throw new Error("Sequence number validation failed - possible replay or DoS attack");
}
if (expectedMessageType && aad.messageType !== expectedMessageType) {
throw new Error(`AAD messageType mismatch - expected ${expectedMessageType}, got ${aad.messageType}`);
}
return aad;
} catch (error) {
this._secureLog("error", "AAD validation failed", {
error: error.message,
aadLength: typeof aadString === "string" ? aadString.length : 0
});
throw new Error(`AAD validation failed: ${error.message}`);
}
}
/**
* Get anti-replay protection status
* This shows the current state of replay protection
*/
getAntiReplayStatus() {
const status = {
replayProtectionEnabled: this.replayProtectionEnabled,
replayWindowSize: this.replayWindowSize,
currentReplayWindowSize: this.replayWindow.size,
sequenceNumber: this.sequenceNumber,
expectedSequenceNumber: this.expectedSequenceNumber,
maxSequenceGap: this.maxSequenceGap,
replayWindowEntries: Array.from(this.replayWindow).sort((a, b) => a - b)
};
this._secureLog("info", "Anti-replay status retrieved", status);
return status;
}
/**
* Configure anti-replay protection
* This allows fine-tuning of replay protection parameters
*/
configureAntiReplayProtection(config) {
try {
if (config.windowSize !== void 0) {
if (config.windowSize < 16 || config.windowSize > 1024) {
throw new Error("Replay window size must be between 16 and 1024");
}
this.replayWindowSize = config.windowSize;
}
if (config.maxGap !== void 0) {
if (config.maxGap < 10 || config.maxGap > 1e3) {
throw new Error("Max sequence gap must be between 10 and 1000");
}
this.maxSequenceGap = config.maxGap;
}
if (config.enabled !== void 0) {
this.replayProtectionEnabled = config.enabled;
}
this._secureLog("info", "Anti-replay protection configured", config);
return true;
} catch (error) {
this._secureLog("error", "Failed to configure anti-replay protection", { error: error.message });
return false;
}
}
/**
* Get real security level with actual cryptographic tests
* This provides real-time verification of security features
*
* Returns the scored security level (level / score / passedChecks / ) with
* the per-feature flags merged in. The header renders `level` and `score`
* directly, so this MUST carry them returning only the feature flags makes
* the header display "Secure undefined%".
*/
async getRealSecurityLevel() {
try {
const featureFlags = {
// Basic security features
ecdhKeyExchange: !!this.ecdhKeyPair,
ecdsaSignatures: !!this.ecdsaKeyPair,
aesEncryption: !!this.encryptionKey,
messageIntegrity: !!this.hmacKey,
// Advanced security features - using the exact property names expected by EnhancedSecureCryptoUtils
replayProtection: this.replayProtectionEnabled,
dtlsFingerprint: !!this.expectedDTLSFingerprint,
sasCode: !!this.verificationCode,
metadataProtection: true,
// Always enabled
trafficObfuscation: true,
// Always enabled
perfectForwardSecrecy: true,
// Always enabled
// Rate limiting
rateLimiter: true,
// Always enabled
// Additional info
connectionId: this.connectionId,
keyFingerprint: this.keyFingerprint,
currentSecurityLevel: "maximum",
timestamp: Date.now()
};
const scored = await this.calculateAndReportSecurityLevel();
if (!scored) {
return {
...featureFlags,
level: "INITIALIZING",
score: 0,
isRealData: false
};
}
return { ...scored, ...featureFlags };
} catch (error) {
this._secureLog("error", "Failed to calculate real security level", { error: error.message });
throw error;
}
}
/**
* Extract DTLS fingerprint from SDP
* This is essential for MITM protection
@@ -12910,6 +13121,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
"file_transfer_error"
];
if (parsed.type && fileMessageTypes2.includes(parsed.type)) {
if (!this._enforceVerificationGate("file_message_receive", false)) {
this._secureLog("error", "Dropped file message received before verification", {
messageType: parsed.type
});
return;
}
if (!this.fileTransferSystem) {
try {
if (this.isVerified && this.dataChannel && this.dataChannel.readyState === "open") {
@@ -12979,26 +13196,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.handleSystemMessage(parsed);
return;
}
if (parsed.type === "message" && parsed.data) {
if (!this._checkInboundRateLimit("dataChannel:message")) {
return;
}
if (this.onMessage) {
this.deliverMessageToUI(parsed.data, "received", parsed.meta);
}
return;
}
if (parsed.type === "enhanced_message" && parsed.data) {
await this._processEnhancedMessageWithoutMutex(parsed);
return;
}
} catch (jsonError) {
if (!this._checkInboundRateLimit("dataChannel:text")) {
this._secureLog("error", "Rejected unencrypted frame on the chat channel", {
messageType: typeof parsed.type === "string" ? parsed.type.slice(0, 32) : typeof parsed.type
});
return;
}
if (this.onMessage) {
this.deliverMessageToUI(event.data, "received");
}
} catch (jsonError) {
this._secureLog("error", "Rejected malformed (non-JSON) frame on the chat channel", {
dataLength: typeof event.data === "string" ? event.data.length : 0
});
return;
}
} else if (event.data instanceof ArrayBuffer) {
@@ -13047,9 +13256,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
} catch (e) {
}
if (this.onMessage) {
this.deliverMessageToUI(textData, "received");
}
this._secureLog("error", "Rejected unauthenticated binary frame on the chat channel", {
byteLength: data?.byteLength || 0
});
}
} catch (error) {
this._secureLog("error", "Error processing binary data:", { errorType: error?.constructor?.name || "Unknown" });
@@ -13071,6 +13280,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.macKey,
this.metadataKey
);
if (!this._validateIncomingSequenceNumber(decryptedResult?.sequenceNumber, "enhanced_message")) {
this._secureLog("error", "Rejected chat message failing anti-replay validation", {
messageId: decryptedResult?.messageId ? "present" : "absent"
});
return;
}
if (decryptedResult && decryptedResult.message) {
try {
const decryptedContent = JSON.parse(decryptedResult.message);
@@ -15666,12 +15881,20 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
this.deliverMessageToUI("Both parties confirmed! Opening secure chat in 2 seconds...", "system");
setTimeout(() => {
try {
this._setVerifiedStatus(true, "MUTUAL_SAS_CONFIRMED", {
code: this.verificationCode,
timestamp: Date.now()
});
this._enforceVerificationGate("mutual_confirmed", false);
this.onStatusChange?.("verified");
} catch (error) {
this._secureLog("error", "Verified transition rejected - aborting session", {
errorType: error?.constructor?.name || "Unknown"
});
this.deliverMessageToUI("Verification could not be completed safely. Connection aborted.", "system");
this.disconnect();
}
}, 2e3);
}
}
@@ -15691,6 +15914,17 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
if (this.bothVerificationsConfirmed) {
return;
}
if (!this.localVerificationConfirmed) {
this._secureLog("error", "Peer claimed mutual SAS confirmation before local confirmation - possible MITM attack", {
localConfirmed: this.localVerificationConfirmed,
remoteConfirmed: this.remoteVerificationConfirmed,
timestamp: Date.now()
});
this.deliverMessageToUI("Verification protocol violation: peer claimed confirmation before you verified the code. Connection aborted for safety.", "system");
this.disconnect();
return;
}
this.remoteVerificationConfirmed = true;
this.bothVerificationsConfirmed = true;
if (this.onVerificationStateChange) {
this.onVerificationStateChange({
@@ -15710,7 +15944,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}, 2e3);
}
handleVerificationRequest(data) {
if (data.code === this.verificationCode) {
if (this._validateSASCode(data?.code)) {
const responsePayload = {
type: "verification_response",
data: {
@@ -15738,8 +15972,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
};
this.dataChannel.send(JSON.stringify(responsePayload));
this._secureLog("error", "SAS verification failed - possible MITM attack", {
receivedCode: data.code,
expectedCode: this.verificationCode,
// Never log the codes themselves — the SAS is the one secret the
// user is asked to compare out-of-band.
receivedCodeLength: typeof data?.code === "string" ? data.code.length : 0,
timestamp: Date.now()
});
this.deliverMessageToUI("SAS verification failed! Possible MITM attack detected. Connection aborted for safety!", "system");
@@ -15751,16 +15986,22 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this._secureLog("warn", "Invalid SAS announcement received from peer");
return;
}
if (this.verificationCode && !this._validateSASCode(data.code)) {
if (!this.verificationCode) {
this._secureLog("error", "Received peer SAS announcement before local SAS was derived - refusing to adopt it", {
timestamp: Date.now()
});
this.deliverMessageToUI("Verification failed: no locally derived code to compare against. Connection aborted for safety.", "system");
this.disconnect();
return;
}
if (!this._validateSASCode(data.code)) {
this._secureLog("error", "Peer-announced SAS does not match locally computed SAS");
this.deliverMessageToUI("Version or SAS mismatch detected. Connection aborted for safety.", "system");
this.disconnect();
return;
}
this.verificationCode = data.code;
this._notifyVerificationReadyIfPossible();
this._secureLog("info", "SAS code received from Offer side", {
sasCode: this.verificationCode,
this._secureLog("info", "Peer SAS announcement matched locally derived code", {
timestamp: Date.now()
});
}
@@ -17479,182 +17720,6 @@ var SecureKeyStorage = class {
return false;
}
}
// Method _generateNextSequenceNumber moved to constructor area for early availability
/**
* Validate incoming message sequence number
* This prevents replay attacks and ensures message ordering
*/
_validateIncomingSequenceNumber(receivedSeq, context = "unknown") {
try {
if (!this.replayProtectionEnabled) {
return true;
}
if (receivedSeq < this.expectedSequenceNumber - this.replayWindowSize) {
this._secureLog("warn", "Sequence number too old - possible replay attack", {
received: receivedSeq,
expected: this.expectedSequenceNumber,
context,
timestamp: Date.now()
});
return false;
}
if (receivedSeq > this.expectedSequenceNumber + this.maxSequenceGap) {
this._secureLog("warn", "Sequence number gap too large - possible DoS attack", {
received: receivedSeq,
expected: this.expectedSequenceNumber,
gap: receivedSeq - this.expectedSequenceNumber,
context,
timestamp: Date.now()
});
return false;
}
if (this.replayWindow.has(receivedSeq)) {
this._secureLog("warn", "Duplicate sequence number detected - replay attack", {
received: receivedSeq,
context,
timestamp: Date.now()
});
return false;
}
this.replayWindow.add(receivedSeq);
if (this.replayWindow.size > this.replayWindowSize) {
const oldestSeq = Math.min(...this.replayWindow);
this.replayWindow.delete(oldestSeq);
}
if (receivedSeq === this.expectedSequenceNumber) {
this.expectedSequenceNumber++;
while (this.replayWindow.has(this.expectedSequenceNumber - this.replayWindowSize - 1)) {
this.replayWindow.delete(this.expectedSequenceNumber - this.replayWindowSize - 1);
}
}
this._secureLog("debug", "Sequence number validation successful", {
received: receivedSeq,
expected: this.expectedSequenceNumber,
context,
timestamp: Date.now()
});
return true;
} catch (error) {
this._secureLog("error", "Sequence number validation failed", {
error: error.message,
context,
timestamp: Date.now()
});
return false;
}
}
// Method _createMessageAAD moved to constructor area for early availability
/**
* Validate message AAD with sequence number
* This ensures message integrity and prevents replay attacks
*/
_validateMessageAAD(aadString, expectedMessageType = null) {
try {
const aad = JSON.parse(aadString);
if (aad.sessionId !== (this.currentSession?.sessionId || "unknown")) {
throw new Error("AAD sessionId mismatch - possible replay attack");
}
if (aad.keyFingerprint !== (this.keyFingerprint || "unknown")) {
throw new Error("AAD keyFingerprint mismatch - possible key substitution attack");
}
if (!this._validateIncomingSequenceNumber(aad.sequenceNumber, aad.messageType)) {
throw new Error("Sequence number validation failed - possible replay or DoS attack");
}
if (expectedMessageType && aad.messageType !== expectedMessageType) {
throw new Error(`AAD messageType mismatch - expected ${expectedMessageType}, got ${aad.messageType}`);
}
return aad;
} catch (error) {
this._secureLog("error", "AAD validation failed", {
error: error.message,
aadLength: typeof aadString === "string" ? aadString.length : 0
});
throw new Error(`AAD validation failed: ${error.message}`);
}
}
/**
* Get anti-replay protection status
* This shows the current state of replay protection
*/
getAntiReplayStatus() {
const status = {
replayProtectionEnabled: this.replayProtectionEnabled,
replayWindowSize: this.replayWindowSize,
currentReplayWindowSize: this.replayWindow.size,
sequenceNumber: this.sequenceNumber,
expectedSequenceNumber: this.expectedSequenceNumber,
maxSequenceGap: this.maxSequenceGap,
replayWindowEntries: Array.from(this.replayWindow).sort((a, b) => a - b)
};
this._secureLog("info", "Anti-replay status retrieved", status);
return status;
}
/**
* Configure anti-replay protection
* This allows fine-tuning of replay protection parameters
*/
configureAntiReplayProtection(config) {
try {
if (config.windowSize !== void 0) {
if (config.windowSize < 16 || config.windowSize > 1024) {
throw new Error("Replay window size must be between 16 and 1024");
}
this.replayWindowSize = config.windowSize;
}
if (config.maxGap !== void 0) {
if (config.maxGap < 10 || config.maxGap > 1e3) {
throw new Error("Max sequence gap must be between 10 and 1000");
}
this.maxSequenceGap = config.maxGap;
}
if (config.enabled !== void 0) {
this.replayProtectionEnabled = config.enabled;
}
this._secureLog("info", "Anti-replay protection configured", config);
return true;
} catch (error) {
this._secureLog("error", "Failed to configure anti-replay protection", { error: error.message });
return false;
}
}
/**
* Get real security level with actual cryptographic tests
* This provides real-time verification of security features
*/
async getRealSecurityLevel() {
try {
const securityData = {
// Basic security features
ecdhKeyExchange: !!this.ecdhKeyPair,
ecdsaSignatures: !!this.ecdsaKeyPair,
aesEncryption: !!this.encryptionKey,
messageIntegrity: !!this.hmacKey,
// Advanced security features - using the exact property names expected by EnhancedSecureCryptoUtils
replayProtection: this.replayProtectionEnabled,
dtlsFingerprint: !!this.expectedDTLSFingerprint,
sasCode: !!this.verificationCode,
metadataProtection: true,
// Always enabled
trafficObfuscation: true,
// Always enabled
perfectForwardSecrecy: true,
// Always enabled
// Rate limiting
rateLimiter: true,
// Always enabled
// Additional info
connectionId: this.connectionId,
keyFingerprint: this.keyFingerprint,
currentSecurityLevel: "maximum",
timestamp: Date.now()
};
this._secureLog("info", "Real security level calculated", securityData);
return securityData;
} catch (error) {
this._secureLog("error", "Failed to calculate real security level", { error: error.message });
throw error;
}
}
};
var SecureIndexedDBWrapper = class {
constructor(dbName = "SecureKeyStorage", version = 1) {
@@ -18365,7 +18430,7 @@ var SecureMasterKeyManager = class {
* Check if master key is unlocked
*/
isUnlocked() {
return this._isUnlocked && this._masterKey !== null;
return this._isUnlocked && this._keyHandle !== null;
}
/**
* Get session status
@@ -18845,7 +18910,7 @@ Right-click or Ctrl+click to disconnect`,
React.createElement("div", { key: "txt", style: { lineHeight: 1.2, minWidth: 0 } }, [
React.createElement("div", { key: "r1", style: { display: "flex", alignItems: "baseline", gap: "7px" } }, [
React.createElement("span", { key: "n", style: { fontSize: "16px", fontWeight: 800, letterSpacing: "-0.3px", color: "#e8e8eb" } }, "SecureBit"),
React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 500, color: "#56565e" } }, "v5.5.0")
React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 500, color: "#56565e" } }, "v5.5.2")
]),
React.createElement("div", { key: "r2", className: "hidden sm:block", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500 } }, "End-to-end encrypted")
])
+2 -2
View File
File diff suppressed because one or more lines are too long
Vendored
+1 -1
View File
@@ -3589,7 +3589,7 @@ var EnhancedSecureP2PChat = () => {
} catch (error) {
}
}
handleMessage(" SecureBit.chat Enhanced Security Edition v5.5.0 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
handleMessage(" SecureBit.chat Enhanced Security Edition v5.5.2 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
manager.setFileTransferCallbacks(
// Progress callback — drives the voice-note upload/download ring.
(progress) => {
+1 -1
View File
File diff suppressed because one or more lines are too long
+25 -24
View File
@@ -8,14 +8,15 @@
script-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
connect-src 'self' https: wss: ws: stun: stuns: turn: turns:;
img-src 'self' data: https:;
connect-src 'self' stun: stuns: turn: turns:;
img-src 'self' data:;
media-src 'self' blob:;
object-src 'none';
frame-src 'none';
worker-src 'self';
manifest-src 'self';
form-action 'self';
base-uri 'none';
upgrade-insecure-requests;">
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
@@ -23,7 +24,7 @@
<!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1784825757783">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1785125039593">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
@@ -89,7 +90,7 @@
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1784825757783">
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1785125039593">
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
@@ -98,7 +99,7 @@
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1784825757783">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1785125039593">
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35">
@@ -182,7 +183,7 @@
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
both execute in document order after parsing, so React still runs before the
app modules below, but the parser / first paint is no longer blocked. -->
<script defer src="config/ice-servers.js?v=1784825757783"></script>
<script defer src="config/ice-servers.js?v=1785125039593"></script>
<script defer src="libs/react/react.production.min.js"></script>
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
@@ -190,8 +191,8 @@
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
<script defer src="libs/prism/prism.js"></script>
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
<link rel="stylesheet" href="assets/tailwind.css?v=1784825757783">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1784825757783">
<link rel="stylesheet" href="assets/tailwind.css?v=1785125039593">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1785125039593">
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
@@ -199,31 +200,31 @@
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1784825757783">
<link rel="stylesheet" href="src/styles/animations.css?v=1784825757783">
<link rel="stylesheet" href="src/styles/components.css?v=1784825757783">
<link rel="stylesheet" href="src/styles/main.css?v=1785125039593">
<link rel="stylesheet" href="src/styles/animations.css?v=1785125039593">
<link rel="stylesheet" href="src/styles/components.css?v=1785125039593">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1784825757783"></script>
<script defer src="src/scripts/load-async-css.js?v=1785125039593"></script>
<noscript>
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="stylesheet" href="libs/prism/prism.css">
</noscript>
<script defer src="src/scripts/fa-check.js?v=1784825757783"></script>
<script defer src="src/scripts/fa-check.js?v=1785125039593"></script>
<!-- Update Manager - система принудительного обновления -->
<script defer src="src/utils/updateManager.js?v=1784825757783"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1784825757783"></script>
<script type="module" src="dist/qr-local.js?v=1784825757783"></script>
<script type="module" src="src/components/QRScanner.js?v=1784825757783"></script>
<script defer src="src/utils/updateManager.js?v=1785125039593"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1785125039593"></script>
<script type="module" src="dist/qr-local.js?v=1785125039593"></script>
<script type="module" src="src/components/QRScanner.js?v=1785125039593"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1784825757783"></script>
<script type="module" src="dist/app.js?v=1784825757783"></script>
<script type="module" src="dist/app-boot.js?v=1785125039593"></script>
<script type="module" src="dist/app.js?v=1785125039593"></script>
<script defer src="src/scripts/pwa-register.js?v=1784825757783"></script>
<script src="./src/pwa/install-prompt.js?v=1784825757783" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1784825757783" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1784825757783"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1784825757783">
<script defer src="src/scripts/pwa-register.js?v=1785125039593"></script>
<script src="./src/pwa/install-prompt.js?v=1785125039593" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1785125039593" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1785125039593"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1785125039593">
</body>
</html>
+7 -7
View File
@@ -1,10 +1,10 @@
{
"version": "1784825757783",
"buildVersion": "1784825757783",
"appVersion": "5.5.0",
"buildTime": "2026-07-23T16:55:57.822Z",
"buildId": "1784825757783-e544593",
"gitHash": "e544593",
"version": "1785125039593",
"buildVersion": "1785125039593",
"appVersion": "5.5.2",
"buildTime": "2026-07-27T04:03:59.662Z",
"buildId": "1785125039593-b3fcf54",
"gitHash": "b3fcf54",
"generated": true,
"generatedAt": "2026-07-23T16:55:57.823Z"
"generatedAt": "2026-07-27T04:03:59.664Z"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "securebit-chat",
"version": "5.5.0",
"version": "5.5.2",
"description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html",
"scripts": {
@@ -11,7 +11,7 @@
"dev": "npm run build && python -m http.server 8000",
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
"serve": "npx http-server -p 8000",
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs"
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/security-level-shape.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs"
},
"keywords": [
"p2p",
+1 -1
View File
@@ -3533,7 +3533,7 @@ import {
}
}
handleMessage(' SecureBit.chat Enhanced Security Edition v5.5.0 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
handleMessage(' SecureBit.chat Enhanced Security Edition v5.5.2 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
// Setup file transfer callbacks (id-bound to THIS session's manager).
manager.setFileTransferCallbacks(
+1 -1
View File
@@ -559,7 +559,7 @@ const EnhancedMinimalHeader = ({
React.createElement('div', { key: 'txt', style: { lineHeight: 1.2, minWidth: 0 } }, [
React.createElement('div', { key: 'r1', style: { display: 'flex', alignItems: 'baseline', gap: '7px' } }, [
React.createElement('span', { key: 'n', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.3px', color: '#e8e8eb' } }, 'SecureBit'),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, 'v5.5.0')
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, 'v5.5.2')
]),
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, 'End-to-end encrypted')
])
+7 -2
View File
@@ -2368,16 +2368,21 @@ class EnhancedSecureCryptoUtils {
const messageAge = Date.now() - metadata.timestamp;
if (messageAge > 1800000) { // 30 minutes for better UX
throw new Error('Message expired (older than 5 minutes)');
throw new Error('Message expired (older than 30 minutes)');
}
if (expectedSequenceNumber !== null) {
// A sequence number below what we expect means the frame is a
// replay (or badly out of order on a channel that is ordered and
// reliable). Downgrading that to a warning and decrypting anyway
// defeats the purpose of tracking sequence numbers at all.
if (metadata.sequenceNumber < expectedSequenceNumber) {
EnhancedSecureCryptoUtils.secureLog.log('warn', 'Received message with lower sequence number, possible queued message', {
EnhancedSecureCryptoUtils.secureLog.log('error', 'Rejected message with stale sequence number - possible replay', {
expected: expectedSequenceNumber,
received: metadata.sequenceNumber,
messageId: metadata.id
});
throw new Error(`Stale sequence number: expected at least ${expectedSequenceNumber}, got ${metadata.sequenceNumber}`);
} else if (metadata.sequenceNumber > expectedSequenceNumber + 10) {
throw new Error(`Sequence number gap too large: expected around ${expectedSequenceNumber}, got ${metadata.sequenceNumber}`);
}
+385 -250
View File
@@ -3772,6 +3772,20 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
throw new Error('Cannot set verified=true without specifying verification method');
}
// SECURITY: defence in depth. Having ECDH-derived keys proves only that
// *someone* completed the handshake — a MITM has them too. The SAS
// comparison is what proves that someone is the intended peer, so any
// SAS-based transition must be backed by a local confirmation that the
// user actually entered the matching code. No caller may shortcut this.
if (verificationMethod.includes('SAS') && !this.localVerificationConfirmed) {
this._secureLog('error', 'Blocked verified transition without local SAS confirmation', {
verificationMethod: verificationMethod,
localConfirmed: this.localVerificationConfirmed,
remoteConfirmed: this.remoteVerificationConfirmed
});
throw new Error('Cannot set verified=true without local SAS confirmation');
}
// Log the verification for audit trail
this._secureLog('info', 'Connection verified through cryptographic verification', {
verificationMethod: verificationMethod,
@@ -3846,6 +3860,261 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
}
}
// ============================================
// ANTI-REPLAY / SEQUENCE VALIDATION
// These belong to the connection, not to key storage: they read and mutate
// this.replayWindow / this.expectedSequenceNumber / this.currentSession.
// ============================================
// Method _generateNextSequenceNumber moved to constructor area for early availability
/**
* Validate incoming message sequence number
* This prevents replay attacks and ensures message ordering
*/
_validateIncomingSequenceNumber(receivedSeq, context = 'unknown') {
try {
if (!this.replayProtectionEnabled) {
return true; // Skip validation if disabled
}
// SECURITY: a missing or non-numeric sequence number must fail closed.
// Both range checks below compare with `<` / `>`, and any comparison
// against undefined/NaN is false — so without this guard an omitted
// sequence number would sail past every check and land in the replay
// window as a valid entry, silently disabling replay protection.
if (typeof receivedSeq !== 'number' || !Number.isFinite(receivedSeq)) {
this._secureLog('warn', 'Missing or non-numeric sequence number - rejecting', {
receivedType: typeof receivedSeq,
context: context,
timestamp: Date.now()
});
return false;
}
// Check if sequence number is within acceptable range
if (receivedSeq < this.expectedSequenceNumber - this.replayWindowSize) {
this._secureLog('warn', 'Sequence number too old - possible replay attack', {
received: receivedSeq,
expected: this.expectedSequenceNumber,
context: context,
timestamp: Date.now()
});
return false;
}
// Check if sequence number is too far ahead (DoS protection)
if (receivedSeq > this.expectedSequenceNumber + this.maxSequenceGap) {
this._secureLog('warn', 'Sequence number gap too large - possible DoS attack', {
received: receivedSeq,
expected: this.expectedSequenceNumber,
gap: receivedSeq - this.expectedSequenceNumber,
context: context,
timestamp: Date.now()
});
return false;
}
// Check if sequence number is already in replay window
if (this.replayWindow.has(receivedSeq)) {
this._secureLog('warn', 'Duplicate sequence number detected - replay attack', {
received: receivedSeq,
context: context,
timestamp: Date.now()
});
return false;
}
// Add to replay window
this.replayWindow.add(receivedSeq);
// Maintain sliding window size
if (this.replayWindow.size > this.replayWindowSize) {
const oldestSeq = Math.min(...this.replayWindow);
this.replayWindow.delete(oldestSeq);
}
// Update expected sequence number if this is the next expected
if (receivedSeq === this.expectedSequenceNumber) {
this.expectedSequenceNumber++;
// Clean up replay window entries that are no longer needed
while (this.replayWindow.has(this.expectedSequenceNumber - this.replayWindowSize - 1)) {
this.replayWindow.delete(this.expectedSequenceNumber - this.replayWindowSize - 1);
}
}
this._secureLog('debug', 'Sequence number validation successful', {
received: receivedSeq,
expected: this.expectedSequenceNumber,
context: context,
timestamp: Date.now()
});
return true;
} catch (error) {
this._secureLog('error', 'Sequence number validation failed', {
error: error.message,
context: context,
timestamp: Date.now()
});
return false;
}
}
// Method _createMessageAAD moved to constructor area for early availability
/**
* Validate message AAD with sequence number
* This ensures message integrity and prevents replay attacks
*/
_validateMessageAAD(aadString, expectedMessageType = null) {
try {
const aad = JSON.parse(aadString);
// Validate session binding
if (aad.sessionId !== (this.currentSession?.sessionId || 'unknown')) {
throw new Error('AAD sessionId mismatch - possible replay attack');
}
if (aad.keyFingerprint !== (this.keyFingerprint || 'unknown')) {
throw new Error('AAD keyFingerprint mismatch - possible key substitution attack');
}
// Validate sequence number
if (!this._validateIncomingSequenceNumber(aad.sequenceNumber, aad.messageType)) {
throw new Error('Sequence number validation failed - possible replay or DoS attack');
}
// Validate message type if specified
if (expectedMessageType && aad.messageType !== expectedMessageType) {
throw new Error(`AAD messageType mismatch - expected ${expectedMessageType}, got ${aad.messageType}`);
}
return aad;
} catch (error) {
// Never log the raw AAD: it carries sessionId and keyFingerprint.
// Length is enough to diagnose malformed input without leaking secrets.
this._secureLog('error', 'AAD validation failed', {
error: error.message,
aadLength: typeof aadString === 'string' ? aadString.length : 0
});
throw new Error(`AAD validation failed: ${error.message}`);
}
}
/**
* Get anti-replay protection status
* This shows the current state of replay protection
*/
getAntiReplayStatus() {
const status = {
replayProtectionEnabled: this.replayProtectionEnabled,
replayWindowSize: this.replayWindowSize,
currentReplayWindowSize: this.replayWindow.size,
sequenceNumber: this.sequenceNumber,
expectedSequenceNumber: this.expectedSequenceNumber,
maxSequenceGap: this.maxSequenceGap,
replayWindowEntries: Array.from(this.replayWindow).sort((a, b) => a - b)
};
this._secureLog('info', 'Anti-replay status retrieved', status);
return status;
}
/**
* Configure anti-replay protection
* This allows fine-tuning of replay protection parameters
*/
configureAntiReplayProtection(config) {
try {
if (config.windowSize !== undefined) {
if (config.windowSize < 16 || config.windowSize > 1024) {
throw new Error('Replay window size must be between 16 and 1024');
}
this.replayWindowSize = config.windowSize;
}
if (config.maxGap !== undefined) {
if (config.maxGap < 10 || config.maxGap > 1000) {
throw new Error('Max sequence gap must be between 10 and 1000');
}
this.maxSequenceGap = config.maxGap;
}
if (config.enabled !== undefined) {
this.replayProtectionEnabled = config.enabled;
}
this._secureLog('info', 'Anti-replay protection configured', config);
return true;
} catch (error) {
this._secureLog('error', 'Failed to configure anti-replay protection', { error: error.message });
return false;
}
}
/**
* Get real security level with actual cryptographic tests
* This provides real-time verification of security features
*
* Returns the scored security level (level / score / passedChecks / ) with
* the per-feature flags merged in. The header renders `level` and `score`
* directly, so this MUST carry them returning only the feature flags makes
* the header display "Secure undefined%".
*/
async getRealSecurityLevel() {
try {
const featureFlags = {
// Basic security features
ecdhKeyExchange: !!this.ecdhKeyPair,
ecdsaSignatures: !!this.ecdsaKeyPair,
aesEncryption: !!this.encryptionKey,
messageIntegrity: !!this.hmacKey,
// Advanced security features - using the exact property names expected by EnhancedSecureCryptoUtils
replayProtection: this.replayProtectionEnabled,
dtlsFingerprint: !!this.expectedDTLSFingerprint,
sasCode: !!this.verificationCode,
metadataProtection: true, // Always enabled
trafficObfuscation: true, // Always enabled
perfectForwardSecrecy: true, // Always enabled
// Rate limiting
rateLimiter: true, // Always enabled
// Additional info
connectionId: this.connectionId,
keyFingerprint: this.keyFingerprint,
currentSecurityLevel: 'maximum',
timestamp: Date.now()
};
// Run the same verified scoring the rest of the app uses, so every
// consumer sees one consistent number.
const scored = await this.calculateAndReportSecurityLevel();
if (!scored) {
// Not ready yet (not connected/verified, or keys still missing).
// isRealData:false tells the UI to keep its previous value rather
// than render a placeholder as if it were a measurement.
return {
...featureFlags,
level: 'INITIALIZING',
score: 0,
isRealData: false
};
}
return { ...scored, ...featureFlags };
} catch (error) {
this._secureLog('error', 'Failed to calculate real security level', { error: error.message });
throw error;
}
}
/**
* Extract DTLS fingerprint from SDP
* This is essential for MITM protection
@@ -7932,6 +8201,21 @@ async processMessage(data) {
if (parsed.type && fileMessageTypes.includes(parsed.type)) {
// SECURITY: file control frames are written straight to
// the data channel by the transfer system, so they never
// pass through sendMessage()'s gate. The send side is
// already gated (see sendFile); gate the receive side too
// so an unverified peer cannot open transfers, push
// chunks or drive transfer state before the SAS has been
// compared. Nothing legitimate arrives here earlier: the
// chat UI only opens once the session is verified.
if (!this._enforceVerificationGate('file_message_receive', false)) {
this._secureLog('error', 'Dropped file message received before verification', {
messageType: parsed.type
});
return;
}
if (!this.fileTransferSystem) {
try {
if (this.isVerified && this.dataChannel && this.dataChannel.readyState === 'open') {
@@ -8011,20 +8295,6 @@ async processMessage(data) {
return;
}
// ============================================
// REGULAR USER MESSAGES (WITHOUT MUTEX)
// ============================================
if (parsed.type === 'message' && parsed.data) {
if (!this._checkInboundRateLimit('dataChannel:message')) {
return;
}
if (this.onMessage) {
this.deliverMessageToUI(parsed.data, 'received', parsed.meta);
}
return;
}
// ============================================
// ENHANCED MESSAGES (WITHOUT MUTEX)
// ============================================
@@ -8034,15 +8304,26 @@ async processMessage(data) {
return;
}
// SECURITY: chat content reaches the UI through exactly one
// door — `enhanced_message`, which is AES-GCM encrypted,
// HMAC-authenticated and AAD/sequence-checked. A bare
// `{type:'message'}` frame used to be rendered as-is, which
// let anyone on the channel inject unauthenticated text that
// looked identical to real messages and bypassed E2EE
// entirely. sendMessage() has always encrypted, so nothing
// legitimate produces such a frame: drop it loudly.
this._secureLog('error', 'Rejected unencrypted frame on the chat channel', {
messageType: typeof parsed.type === 'string' ? parsed.type.slice(0, 32) : typeof parsed.type
});
return;
} catch (jsonError) {
// Not JSON — treat as regular text message
if (!this._checkInboundRateLimit('dataChannel:text')) {
return;
}
if (this.onMessage) {
this.deliverMessageToUI(event.data, 'received');
}
// SECURITY: non-JSON payloads were previously rendered in the
// chat as plain text — the same unauthenticated injection
// vector as above. Every legitimate frame is JSON.
this._secureLog('error', 'Rejected malformed (non-JSON) frame on the chat channel', {
dataLength: typeof event.data === 'string' ? event.data.length : 0
});
return;
}
} else if (event.data instanceof ArrayBuffer) {
@@ -8100,20 +8381,24 @@ async processMessage(data) {
if (processedData instanceof ArrayBuffer) {
const textData = new TextDecoder().decode(processedData);
// Check for fake messages
// SECURITY: the only thing legitimately sent as a binary frame is
// cover traffic (see sendFakeMessage), which exists purely to be
// discarded. Real chat content always arrives as an authenticated
// `enhanced_message`. Decoding a binary frame and handing it to the
// UI would therefore only ever surface unauthenticated peer input,
// so nothing on this path may reach the user.
try {
const content = JSON.parse(textData);
if (content.type === 'fake' || content.isFakeTraffic === true) {
return;
}
} catch (e) {
// Not JSON — fine for plain text
// Not JSON — still not deliverable.
}
// Deliver message to user
if (this.onMessage) {
this.deliverMessageToUI(textData, 'received');
}
this._secureLog('error', 'Rejected unauthenticated binary frame on the chat channel', {
byteLength: data?.byteLength || 0
});
}
} catch (error) {
@@ -8139,6 +8424,19 @@ async processMessage(data) {
this.metadataKey
);
// SECURITY: enforce the anti-replay window. The sequence number lives
// inside the AES-GCM-encrypted, HMAC-covered metadata, so it cannot be
// forged or rewritten in transit — but until it is actually checked
// against the sliding window, a captured frame could simply be played
// back. The main data channel is ordered+reliable, so legitimate
// messages always arrive in sequence.
if (!this._validateIncomingSequenceNumber(decryptedResult?.sequenceNumber, 'enhanced_message')) {
this._secureLog('error', 'Rejected chat message failing anti-replay validation', {
messageId: decryptedResult?.messageId ? 'present' : 'absent'
});
return;
}
if (decryptedResult && decryptedResult.message) {
// Try parsing JSON and showing nested text if it's a chat message
@@ -11630,12 +11928,23 @@ async processMessage(data) {
this.deliverMessageToUI('Both parties confirmed! Opening secure chat in 2 seconds...', 'system');
setTimeout(() => {
try {
this._setVerifiedStatus(true, 'MUTUAL_SAS_CONFIRMED', {
code: this.verificationCode,
timestamp: Date.now()
});
this._enforceVerificationGate('mutual_confirmed', false);
this.onStatusChange?.('verified');
} catch (error) {
// The verified transition is guarded (see _setVerifiedStatus).
// If it refuses, the session is not trustworthy — tear it down
// instead of leaving the UI in a half-verified state.
this._secureLog('error', 'Verified transition rejected - aborting session', {
errorType: error?.constructor?.name || 'Unknown'
});
this.deliverMessageToUI('Verification could not be completed safely. Connection aborted.', 'system');
this.disconnect();
}
}, 2000);
}
}
@@ -11668,6 +11977,28 @@ async processMessage(data) {
return;
}
// SECURITY: this frame is unauthenticated peer input on an as-yet
// unverified channel. It may only ACKNOWLEDGE a mutual confirmation, never
// create one. Without this check a MITM who completed the signalling
// exchange could send a single `verification_both_confirmed` right after
// the data channel opens and drive us to isVerified=true while the user
// never compared the SAS out-of-band — defeating the entire MITM defence.
if (!this.localVerificationConfirmed) {
this._secureLog('error', 'Peer claimed mutual SAS confirmation before local confirmation - possible MITM attack', {
localConfirmed: this.localVerificationConfirmed,
remoteConfirmed: this.remoteVerificationConfirmed,
timestamp: Date.now()
});
this.deliverMessageToUI('Verification protocol violation: peer claimed confirmation before you verified the code. Connection aborted for safety.', 'system');
this.disconnect();
return;
}
// The peer can only reach "both confirmed" after confirming on its side,
// so record that too — otherwise the state reported to the UI is
// inconsistent with the transition we are about to make.
this.remoteVerificationConfirmed = true;
// Handle notification that both parties have confirmed
this.bothVerificationsConfirmed = true;
@@ -11696,7 +12027,8 @@ async processMessage(data) {
handleVerificationRequest(data) {
if (data.code === this.verificationCode) {
// Constant-time comparison, consistent with confirmVerification().
if (this._validateSASCode(data?.code)) {
const responsePayload = {
type: 'verification_response',
data: {
@@ -11728,8 +12060,9 @@ async processMessage(data) {
this.dataChannel.send(JSON.stringify(responsePayload));
this._secureLog('error', 'SAS verification failed - possible MITM attack', {
receivedCode: data.code,
expectedCode: this.verificationCode,
// Never log the codes themselves — the SAS is the one secret the
// user is asked to compare out-of-band.
receivedCodeLength: typeof data?.code === 'string' ? data.code.length : 0,
timestamp: Date.now()
});
@@ -11744,18 +12077,32 @@ async processMessage(data) {
return;
}
if (this.verificationCode && !this._validateSASCode(data.code)) {
// SECURITY: the peer's announcement may only CORROBORATE the SAS we
// derived ourselves from the shared ECDH secret and both DTLS
// fingerprints — never supply it. Adopting a peer-provided code would
// mean showing the user a number chosen by whoever is on the other end,
// so an attacker could simply announce the same value to both sides and
// the out-of-band comparison would always "succeed".
if (!this.verificationCode) {
this._secureLog('error', 'Received peer SAS announcement before local SAS was derived - refusing to adopt it', {
timestamp: Date.now()
});
this.deliverMessageToUI('Verification failed: no locally derived code to compare against. Connection aborted for safety.', 'system');
this.disconnect();
return;
}
if (!this._validateSASCode(data.code)) {
this._secureLog('error', 'Peer-announced SAS does not match locally computed SAS');
this.deliverMessageToUI('Version or SAS mismatch detected. Connection aborted for safety.', 'system');
this.disconnect();
return;
}
this.verificationCode = data.code;
// Codes agree — keep our own locally derived value and continue.
this._notifyVerificationReadyIfPossible();
this._secureLog('info', 'SAS code received from Offer side', {
sasCode: this.verificationCode,
this._secureLog('info', 'Peer SAS announcement matched locally derived code', {
timestamp: Date.now()
});
}
@@ -13774,221 +14121,6 @@ class SecureKeyStorage {
}
}
// Method _generateNextSequenceNumber moved to constructor area for early availability
/**
* Validate incoming message sequence number
* This prevents replay attacks and ensures message ordering
*/
_validateIncomingSequenceNumber(receivedSeq, context = 'unknown') {
try {
if (!this.replayProtectionEnabled) {
return true; // Skip validation if disabled
}
// Check if sequence number is within acceptable range
if (receivedSeq < this.expectedSequenceNumber - this.replayWindowSize) {
this._secureLog('warn', 'Sequence number too old - possible replay attack', {
received: receivedSeq,
expected: this.expectedSequenceNumber,
context: context,
timestamp: Date.now()
});
return false;
}
// Check if sequence number is too far ahead (DoS protection)
if (receivedSeq > this.expectedSequenceNumber + this.maxSequenceGap) {
this._secureLog('warn', 'Sequence number gap too large - possible DoS attack', {
received: receivedSeq,
expected: this.expectedSequenceNumber,
gap: receivedSeq - this.expectedSequenceNumber,
context: context,
timestamp: Date.now()
});
return false;
}
// Check if sequence number is already in replay window
if (this.replayWindow.has(receivedSeq)) {
this._secureLog('warn', 'Duplicate sequence number detected - replay attack', {
received: receivedSeq,
context: context,
timestamp: Date.now()
});
return false;
}
// Add to replay window
this.replayWindow.add(receivedSeq);
// Maintain sliding window size
if (this.replayWindow.size > this.replayWindowSize) {
const oldestSeq = Math.min(...this.replayWindow);
this.replayWindow.delete(oldestSeq);
}
// Update expected sequence number if this is the next expected
if (receivedSeq === this.expectedSequenceNumber) {
this.expectedSequenceNumber++;
// Clean up replay window entries that are no longer needed
while (this.replayWindow.has(this.expectedSequenceNumber - this.replayWindowSize - 1)) {
this.replayWindow.delete(this.expectedSequenceNumber - this.replayWindowSize - 1);
}
}
this._secureLog('debug', 'Sequence number validation successful', {
received: receivedSeq,
expected: this.expectedSequenceNumber,
context: context,
timestamp: Date.now()
});
return true;
} catch (error) {
this._secureLog('error', 'Sequence number validation failed', {
error: error.message,
context: context,
timestamp: Date.now()
});
return false;
}
}
// Method _createMessageAAD moved to constructor area for early availability
/**
* Validate message AAD with sequence number
* This ensures message integrity and prevents replay attacks
*/
_validateMessageAAD(aadString, expectedMessageType = null) {
try {
const aad = JSON.parse(aadString);
// Validate session binding
if (aad.sessionId !== (this.currentSession?.sessionId || 'unknown')) {
throw new Error('AAD sessionId mismatch - possible replay attack');
}
if (aad.keyFingerprint !== (this.keyFingerprint || 'unknown')) {
throw new Error('AAD keyFingerprint mismatch - possible key substitution attack');
}
// Validate sequence number
if (!this._validateIncomingSequenceNumber(aad.sequenceNumber, aad.messageType)) {
throw new Error('Sequence number validation failed - possible replay or DoS attack');
}
// Validate message type if specified
if (expectedMessageType && aad.messageType !== expectedMessageType) {
throw new Error(`AAD messageType mismatch - expected ${expectedMessageType}, got ${aad.messageType}`);
}
return aad;
} catch (error) {
// Never log the raw AAD: it carries sessionId and keyFingerprint.
// Length is enough to diagnose malformed input without leaking secrets.
this._secureLog('error', 'AAD validation failed', {
error: error.message,
aadLength: typeof aadString === 'string' ? aadString.length : 0
});
throw new Error(`AAD validation failed: ${error.message}`);
}
}
/**
* Get anti-replay protection status
* This shows the current state of replay protection
*/
getAntiReplayStatus() {
const status = {
replayProtectionEnabled: this.replayProtectionEnabled,
replayWindowSize: this.replayWindowSize,
currentReplayWindowSize: this.replayWindow.size,
sequenceNumber: this.sequenceNumber,
expectedSequenceNumber: this.expectedSequenceNumber,
maxSequenceGap: this.maxSequenceGap,
replayWindowEntries: Array.from(this.replayWindow).sort((a, b) => a - b)
};
this._secureLog('info', 'Anti-replay status retrieved', status);
return status;
}
/**
* Configure anti-replay protection
* This allows fine-tuning of replay protection parameters
*/
configureAntiReplayProtection(config) {
try {
if (config.windowSize !== undefined) {
if (config.windowSize < 16 || config.windowSize > 1024) {
throw new Error('Replay window size must be between 16 and 1024');
}
this.replayWindowSize = config.windowSize;
}
if (config.maxGap !== undefined) {
if (config.maxGap < 10 || config.maxGap > 1000) {
throw new Error('Max sequence gap must be between 10 and 1000');
}
this.maxSequenceGap = config.maxGap;
}
if (config.enabled !== undefined) {
this.replayProtectionEnabled = config.enabled;
}
this._secureLog('info', 'Anti-replay protection configured', config);
return true;
} catch (error) {
this._secureLog('error', 'Failed to configure anti-replay protection', { error: error.message });
return false;
}
}
/**
* Get real security level with actual cryptographic tests
* This provides real-time verification of security features
*/
async getRealSecurityLevel() {
try {
const securityData = {
// Basic security features
ecdhKeyExchange: !!this.ecdhKeyPair,
ecdsaSignatures: !!this.ecdsaKeyPair,
aesEncryption: !!this.encryptionKey,
messageIntegrity: !!this.hmacKey,
// Advanced security features - using the exact property names expected by EnhancedSecureCryptoUtils
replayProtection: this.replayProtectionEnabled,
dtlsFingerprint: !!this.expectedDTLSFingerprint,
sasCode: !!this.verificationCode,
metadataProtection: true, // Always enabled
trafficObfuscation: true, // Always enabled
perfectForwardSecrecy: true, // Always enabled
// Rate limiting
rateLimiter: true, // Always enabled
// Additional info
connectionId: this.connectionId,
keyFingerprint: this.keyFingerprint,
currentSecurityLevel: 'maximum',
timestamp: Date.now()
};
this._secureLog('info', 'Real security level calculated', securityData);
return securityData;
} catch (error) {
this._secureLog('error', 'Failed to calculate real security level', { error: error.message });
throw error;
}
}
}
/**
@@ -14898,7 +15030,10 @@ class SecureMasterKeyManager {
* Check if master key is unlocked
*/
isUnlocked() {
return this._isUnlocked && this._masterKey !== null;
// The derived key lives in _keyHandle (non-extractable CryptoKey). This
// used to test a long-renamed `_masterKey` field, which was always
// undefined and therefore never actually gated anything.
return this._isUnlocked && this._keyHandle !== null;
}
/**
+1 -1
View File
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1784825757783';
const SW_BUILD_VERSION = '1785125039593';
// Load version from meta.json on install
async function getAppVersion() {
+193
View File
@@ -0,0 +1,193 @@
// Regression tests for inbound frame authentication.
//
// Chat content must reach the UI through exactly one door: an `enhanced_message`
// that has been AES-GCM decrypted, HMAC-verified and replay-checked. Anything
// else arriving on the data channel is unauthenticated peer input and must be
// dropped rather than rendered as if it were a real message.
import assert from 'node:assert/strict';
globalThis.window = globalThis.window || {};
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
function createReceiver({ isVerified = true } = {}) {
const delivered = [];
const receiver = {
delivered,
isVerified,
encryptionKey: null,
macKey: null,
metadataKey: null,
securityFeatures: {},
onMessage: (m, t) => delivered.push({ message: m, type: t }),
deliverMessageToUI(msg, type) { this.onMessage(msg, type); },
_secureLog() {},
_checkInboundRateLimit: () => true,
establishConnection: async () => {},
initializeFileTransfer() {},
startHeartbeat() {},
_notifyVerificationReadyIfPossible() {},
initiateVerification() {},
setupDataChannel: P.setupDataChannel,
_processBinaryDataWithoutMutex: P._processBinaryDataWithoutMutex,
_processEnhancedMessageWithoutMutex: P._processEnhancedMessageWithoutMutex
};
const channel = { readyState: 'open', send() {} };
receiver.setupDataChannel(channel);
return { receiver, channel };
}
// ── unencrypted {type:'message'} frames are rejected ─────────────────────────
{
const { receiver, channel } = createReceiver();
await channel.onmessage({
data: JSON.stringify({ type: 'message', data: 'injected without any crypto' })
});
assert.deepEqual(receiver.delivered, [], 'plaintext message frames must not reach the UI');
}
// ── raw non-JSON text is rejected ────────────────────────────────────────────
{
const { receiver, channel } = createReceiver();
await channel.onmessage({ data: 'not even json' });
assert.deepEqual(receiver.delivered, [], 'raw text frames must not reach the UI');
}
// ── binary frames never reach the UI ─────────────────────────────────────────
{
const { receiver, channel } = createReceiver();
const buf = new TextEncoder().encode('binary injection').buffer;
await channel.onmessage({ data: buf });
assert.deepEqual(receiver.delivered, [], 'binary frames must not reach the UI');
}
// ── cover traffic is still discarded silently ────────────────────────────────
{
const { receiver, channel } = createReceiver();
const fake = new TextEncoder().encode(JSON.stringify({ type: 'fake', isFakeTraffic: true })).buffer;
await channel.onmessage({ data: fake });
assert.deepEqual(receiver.delivered, []);
}
// ── the authenticated path still delivers, and enforces anti-replay ──────────
{
let seq = 0;
globalThis.window.EnhancedSecureCryptoUtils = {
decryptMessage: async () => ({
message: JSON.stringify({ type: 'message', data: `msg-${seq}` }),
messageId: `id-${seq}`,
sequenceNumber: seq
})
};
const delivered = [];
const receiver = {
encryptionKey: {}, macKey: {}, metadataKey: {},
_secureLog() {},
_checkInboundRateLimit: () => true,
_sanitizeIncomingChatMessage: (m) => m,
_sanitizeMessageMeta: P._sanitizeMessageMeta,
_validateIncomingSequenceNumber: P._validateIncomingSequenceNumber,
replayProtectionEnabled: true,
expectedSequenceNumber: 0,
replayWindow: new Set(),
replayWindowSize: 64,
maxSequenceGap: 100,
onMessage: (m) => delivered.push(m),
deliverMessageToUI: P.deliverMessageToUI,
_processEnhancedMessageWithoutMutex: P._processEnhancedMessageWithoutMutex
};
seq = 0;
await receiver._processEnhancedMessageWithoutMutex({ type: 'enhanced_message', data: 'enc' });
seq = 1;
await receiver._processEnhancedMessageWithoutMutex({ type: 'enhanced_message', data: 'enc' });
assert.deepEqual(delivered, ['msg-0', 'msg-1'], 'fresh messages must be delivered');
// Replaying sequence number 0 must be refused.
seq = 0;
await receiver._processEnhancedMessageWithoutMutex({ type: 'enhanced_message', data: 'enc' });
assert.deepEqual(delivered, ['msg-0', 'msg-1'], 'replayed message must be dropped');
}
// ── a missing sequence number fails closed ───────────────────────────────────
{
const receiver = {
replayProtectionEnabled: true,
expectedSequenceNumber: 0,
replayWindow: new Set(),
replayWindowSize: 64,
maxSequenceGap: 100,
_secureLog() {},
_validateIncomingSequenceNumber: P._validateIncomingSequenceNumber
};
assert.equal(receiver._validateIncomingSequenceNumber(undefined, 't'), false);
assert.equal(receiver._validateIncomingSequenceNumber(null, 't'), false);
assert.equal(receiver._validateIncomingSequenceNumber('3', 't'), false);
assert.equal(receiver._validateIncomingSequenceNumber(NaN, 't'), false);
assert.equal(receiver.replayWindow.size, 0, 'rejected values must not pollute the window');
assert.equal(receiver._validateIncomingSequenceNumber(0, 't'), true);
}
// ── file control frames are gated on verification ────────────────────────────
{
const handled = [];
const makeReceiver = (isVerified) => {
const receiver = {
isVerified,
securityFeatures: {},
fileTransferSystem: { handleFileMessage: async (m) => handled.push(m.type) },
_secureLog() {},
_checkInboundRateLimit: () => true,
_enforceVerificationGate: P._enforceVerificationGate,
onMessage() {},
deliverMessageToUI() {},
establishConnection: async () => {},
initializeFileTransfer() {},
startHeartbeat() {},
_notifyVerificationReadyIfPossible() {},
initiateVerification() {},
setupDataChannel: P.setupDataChannel
};
const channel = { readyState: 'open', send() {} };
receiver.setupDataChannel(channel);
return channel;
};
// Unverified peer: file frames must be dropped, not acted on.
const unverified = makeReceiver(false);
await unverified.onmessage({
data: JSON.stringify({ type: 'file_transfer_start', fileId: 'f1', fileName: 'x.png' })
});
await unverified.onmessage({ data: JSON.stringify({ type: 'file_chunk', fileId: 'f1' }) });
assert.deepEqual(handled, [], 'file frames must not be processed before verification');
// Verified peer: transfers keep working exactly as before.
const verified = makeReceiver(true);
await verified.onmessage({
data: JSON.stringify({ type: 'file_transfer_start', fileId: 'f1', fileName: 'x.png' })
});
await verified.onmessage({ data: JSON.stringify({ type: 'file_chunk', fileId: 'f1' }) });
assert.deepEqual(handled, ['file_transfer_start', 'file_chunk'], 'verified transfers must still work');
}
// ── anti-replay helpers live on the connection, not on key storage ───────────
{
for (const name of [
'_validateIncomingSequenceNumber',
'_validateMessageAAD',
'getAntiReplayStatus',
'configureAntiReplayProtection'
]) {
assert.equal(
typeof P[name],
'function',
`${name} must be reachable on the WebRTC manager (it reads this.replayWindow)`
);
}
}
console.log('Inbound frame authentication tests passed');
+9 -1
View File
@@ -66,8 +66,10 @@ const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
// This is the path real chat uses (dataChannel.onmessage -> _processEnhancedMessageWithoutMutex).
{
const envelope = JSON.stringify({ type: 'message', data: 'hi there', meta: { mid: 'm7', once: true, ttl: 30 } });
// decryptMessage always returns the authenticated sequence number alongside
// the plaintext; the receive path uses it for anti-replay validation.
globalThis.window.EnhancedSecureCryptoUtils = {
decryptMessage: async () => ({ message: envelope })
decryptMessage: async () => ({ message: envelope, messageId: 'msg_7', sequenceNumber: 0 })
};
const calls = [];
const manager = {
@@ -76,6 +78,12 @@ const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
_checkInboundRateLimit: () => true,
_sanitizeIncomingChatMessage: (m) => m,
_sanitizeMessageMeta: P._sanitizeMessageMeta,
_validateIncomingSequenceNumber: P._validateIncomingSequenceNumber,
replayProtectionEnabled: true,
expectedSequenceNumber: 0,
replayWindow: new Set(),
replayWindowSize: 64,
maxSequenceGap: 100,
onMessage: (message, type, meta) => calls.push({ message, type, meta }),
deliverMessageToUI: P.deliverMessageToUI
};
+87
View File
@@ -0,0 +1,87 @@
// The header renders `level` and `score` straight from whatever the security
// getter returns:
//
// sec.level || 'Secure' -> label
// sec.score + '%' -> score badge
//
// so any getter the header may call must carry both fields. A getter that
// returned only per-feature booleans rendered as "Secure undefined%".
// These tests pin the shape for every branch of the header's fallback chain.
import assert from 'node:assert/strict';
globalThis.window = globalThis.window || {};
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
const SCORED = {
level: 'HIGH',
score: 90,
color: 'green',
isRealData: true,
passedChecks: 9,
totalChecks: 10
};
function createManager(overrides = {}) {
return Object.assign({
ecdhKeyPair: {},
ecdsaKeyPair: {},
encryptionKey: {},
hmacKey: {},
replayProtectionEnabled: true,
expectedDTLSFingerprint: 'aa:bb',
verificationCode: '1234567',
connectionId: 'conn-1',
keyFingerprint: 'ff:ee',
_secureLog() {},
calculateAndReportSecurityLevel: async () => ({ ...SCORED }),
getRealSecurityLevel: P.getRealSecurityLevel
}, overrides);
}
// ── the scored fields survive, alongside the feature flags ───────────────────
{
const data = await createManager().getRealSecurityLevel();
assert.equal(typeof data.score, 'number', 'score must be numeric (renders as `score + "%"`)');
assert.equal(typeof data.level, 'string', 'level must be a string (renders as the label)');
assert.equal(data.score, 90);
assert.equal(data.level, 'HIGH');
assert.equal(data.isRealData, true);
assert.equal(data.passedChecks, 9);
assert.equal(data.totalChecks, 10);
// Feature flags are still exposed for the detailed security panel.
assert.equal(data.ecdhKeyExchange, true);
assert.equal(data.sasCode, true);
assert.equal(data.replayProtection, true);
// What the header would actually paint.
assert.equal(String(data.level || 'Secure'), 'HIGH');
assert.equal(data.score + '%', '90%');
}
// ── not-ready path is flagged, not rendered as a real measurement ────────────
{
// calculateAndReportSecurityLevel returns null when the session is not yet
// connected/verified or the keys are missing.
const data = await createManager({
calculateAndReportSecurityLevel: async () => null
}).getRealSecurityLevel();
assert.equal(data.isRealData, false, 'UI keeps its previous value when isRealData is false');
assert.equal(typeof data.score, 'number');
assert.equal(typeof data.level, 'string');
assert.notEqual(data.score + '%', 'undefined%');
}
// ── the header's other fallback branch keeps the same contract ───────────────
{
const data = await createManager().calculateAndReportSecurityLevel();
assert.equal(typeof data.score, 'number');
assert.equal(typeof data.level, 'string');
}
console.log('Security level shape tests passed');
+142
View File
@@ -0,0 +1,142 @@
// Regression tests for the MITM-protection gate.
//
// The whole security model rests on one step: the two users compare a Short
// Authentication String out-of-band. Everything below asserts that this step
// cannot be skipped, faked or supplied by the peer — an attacker who completes
// the signalling exchange (and therefore holds valid ECDH-derived keys) must
// still be unable to reach a verified session.
import assert from 'node:assert/strict';
globalThis.window = globalThis.window || {};
globalThis.window.EnhancedSecureCryptoUtils = {
constantTimeCompare: (a, b) => a === b
};
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
const settle = (ms = 2300) => new Promise((r) => setTimeout(r, ms));
function createPeerStub(overrides = {}) {
const stub = {
isVerified: false,
localVerificationConfirmed: false,
remoteVerificationConfirmed: false,
bothVerificationsConfirmed: false,
verificationCode: '1234567',
// Present right after ECDH — a MITM has these too, which is exactly why
// they must not be sufficient to become "verified".
encryptionKey: {},
macKey: {},
keyFingerprint: 'aa:bb:cc',
disconnected: false,
statusChanges: [],
uiMessages: [],
onStatusChange(s) { this.statusChanges.push(s); },
onVerificationStateChange() {},
_secureLog() {},
deliverMessageToUI(msg) { this.uiMessages.push(String(msg)); },
disconnect() { this.disconnected = true; },
dataChannel: { readyState: 'open', send() {} },
handleSystemMessage: P.handleSystemMessage,
handleVerificationBothConfirmed: P.handleVerificationBothConfirmed,
handleVerificationConfirmed: P.handleVerificationConfirmed,
handleSASCode: P.handleSASCode,
_validateSASCode: P._validateSASCode,
_checkBothVerificationsConfirmed: P._checkBothVerificationsConfirmed,
_setVerifiedStatus: P._setVerifiedStatus,
_enforceVerificationGate: P._enforceVerificationGate,
_notifyVerificationReadyIfPossible() {},
handleHeartbeat() {},
handlePeerDisconnectNotification() {},
handleVerificationRequest() {},
handleVerificationResponse() {},
processMessageQueue() {}
};
return Object.assign(stub, overrides);
}
// ── a peer cannot manufacture mutual confirmation ────────────────────────────
{
const victim = createPeerStub();
victim.handleSystemMessage({
type: 'verification_both_confirmed',
data: { timestamp: Date.now(), verificationMethod: 'SAS' }
});
await settle();
assert.equal(victim.isVerified, false, 'peer must not be able to force verified state');
assert.equal(victim.bothVerificationsConfirmed, false);
assert.equal(victim.disconnected, true, 'protocol violation must tear down the session');
assert.ok(
!victim.statusChanges.includes('verified'),
'no verified status may be emitted to the UI'
);
}
// ── the same frame IS honoured once the user has confirmed locally ───────────
{
const victim = createPeerStub({ localVerificationConfirmed: true });
victim.handleSystemMessage({
type: 'verification_both_confirmed',
data: { timestamp: Date.now(), verificationMethod: 'SAS' }
});
await settle();
assert.equal(victim.isVerified, true, 'legitimate mutual confirmation must still work');
assert.equal(victim.remoteVerificationConfirmed, true);
assert.equal(victim.disconnected, false);
assert.ok(victim.statusChanges.includes('verified'));
}
// ── _setVerifiedStatus refuses SAS transitions without local confirmation ────
{
const victim = createPeerStub();
assert.throws(
() => victim._setVerifiedStatus(true, 'MUTUAL_SAS_CONFIRMED', {}),
/without local SAS confirmation/,
'the low-level setter must fail closed too'
);
assert.equal(victim.isVerified, false);
}
// ── holding keys alone is never enough ───────────────────────────────────────
{
const victim = createPeerStub({ encryptionKey: null, macKey: null });
assert.throws(
() => victim._setVerifiedStatus(true, 'MUTUAL_SAS_CONFIRMED', {}),
/without encryption keys/
);
}
// ── a peer-announced SAS may corroborate, never supply ───────────────────────
{
// No locally derived code yet -> the announcement must be refused outright.
const victim = createPeerStub({ verificationCode: null });
victim.handleSystemMessage({ type: 'sas_code', data: { code: '7654321' } });
assert.equal(victim.verificationCode, null, 'peer-supplied SAS must not be adopted');
assert.equal(victim.disconnected, true);
}
{
// Locally derived code present and matching -> accepted, ours retained.
const victim = createPeerStub({ verificationCode: '1234567' });
victim.handleSystemMessage({ type: 'sas_code', data: { code: '1234567' } });
assert.equal(victim.verificationCode, '1234567');
assert.equal(victim.disconnected, false);
}
{
// Mismatch -> abort.
const victim = createPeerStub({ verificationCode: '1234567' });
victim.handleSystemMessage({ type: 'sas_code', data: { code: '7654321' } });
assert.equal(victim.disconnected, true, 'SAS mismatch must abort the session');
}
console.log('Verification gate tests passed');