feat(crypto): Double Ratchet forward secrecy; hardening pass; release v5.7.1
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run

Adds the Double Ratchet (Signal's design) on top of the existing ECDH session
keys, so message protection no longer rests on one set of keys lasting the whole
conversation. Every message gets its own key, derived through a one-way function
and discarded after use, and each change of direction introduces a fresh ECDH
key pair that re-keys the session root.

The ratchet needed no handshake change: both peers already hold each other's
authenticated ECDH public key, and the safety code compared during verification
covers exactly those keys. Its root is derived from the existing shared secret
through its own branch of the key schedule.

Support is negotiated in the invitation and response and used only when both
sides have it; a peer on an earlier release falls back to per-session keys. The
security panel reports which of the two is actually in force.

Out-of-order delivery is supported within fixed bounds (512 skipped keys per
chain, 1024 retained, five-minute expiry), and inbound frames are authenticated
before any ratchet state is committed, so a malformed frame cannot desynchronise
a live session.

Also in this release:

- Verification is enforced as a gate, not a label: control frames (reconnection
  signalling, call setup, message deletion, delivery receipts) are acted on only
  after both peers have compared the safety code, and verified state is set in a
  single guarded place.
- Chat content reaches the interface through one authenticated path; an older,
  weaker inbound path was retired.
- The security panel measures what it displays — several checks previously
  returned a fixed result and now exercise the subsystem they describe.
- Invitation data is no longer kept in local storage, and entries left by earlier
  versions are cleared on first launch.
- View-once and disappearing messages no longer place their text in system
  notifications.
- Shared-secret buffers are overwritten once derivation completes; scanned QR
  codes are decompressed with a size limit; voice notes are validated against
  audio type and size budgets before skipping the consent prompt; the master
  password is collected by the app rather than a browser dialog.
- Connection setup no longer fails on networks where STUN/TURN are unreachable:
  it proceeds as soon as usable candidates exist and only waits while there are
  none.

Test suite grows from 27 to 41 files, covering forward secrecy, post-compromise
re-keying, out-of-order delivery across ratchet steps, the skipped-key bounds,
tamper resistance, negotiation fallback, and byte-level key-derivation
compatibility with 5.6.0.
This commit is contained in:
lockbitchat
2026-08-05 23:02:20 -04:00
parent 2a7142c722
commit 27279ae7c6
38 changed files with 4570 additions and 752 deletions
+1130 -264
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
File diff suppressed because one or more lines are too long
Vendored
+6 -38
View File
@@ -3906,22 +3906,6 @@ var EnhancedSecureP2PChat = () => {
return offerData2;
}
};
const createQRReference = (offerData2) => {
try {
const referenceId = `offer_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem(`qr_offer_${referenceId}`, JSON.stringify(offerData2));
const qrReference = {
type: "secure_offer_reference",
referenceId,
timestamp: Date.now(),
message: "Scan this QR code and use the reference ID to get full offer data"
};
return JSON.stringify(qrReference);
} catch (error) {
console.error("Error creating QR reference:", error);
return null;
}
};
const createTemplateOffer = (offer) => {
const templateOffer = {
type: "enhanced_secure_offer_template",
@@ -4494,28 +4478,12 @@ var EnhancedSecureP2PChat = () => {
}]);
setShowQRScannerModal(false);
return true;
} else if (parsedData.type === "secure_offer_reference" && parsedData.referenceId) {
const fullOfferData = localStorage.getItem(`qr_offer_${parsedData.referenceId}`);
if (fullOfferData) {
const fullOffer = JSON.parse(fullOfferData);
if (showOfferStep) {
setAnswerInput(JSON.stringify(fullOffer, null, 2));
} else {
setOfferInput(JSON.stringify(fullOffer, null, 2));
}
setMessages((prev) => [...prev, {
message: "\u{1F4F1} QR code scanned successfully! Full offer data retrieved.",
type: "success"
}]);
setShowQRScannerModal(false);
return true;
} else {
setMessages((prev) => [...prev, {
message: "QR code reference found but full data not available. Please use copy/paste.",
type: "error"
}]);
return false;
}
} else if (parsedData.type === "secure_offer_reference") {
setMessages((prev) => [...prev, {
message: "This QR code uses a retired format that could not transfer the invitation. Ask your peer to generate a new one, or use copy/paste.",
type: "error"
}]);
return false;
} else {
if (!parsedData.sdp && parsedData.type === "enhanced_secure_offer") {
setMessages((prev) => [...prev, {
+2 -2
View File
File diff suppressed because one or more lines are too long
+30 -2
View File
@@ -36078,6 +36078,7 @@ var { Deflate, deflate, deflateRaw, gzip } = deflate_1$1;
var { Inflate, inflate, inflateRaw, ungzip } = inflate_1$1;
var deflate_1 = deflate;
var gzip_1 = gzip;
var Inflate_1 = Inflate;
var inflate_1 = inflate;
var ungzip_1 = ungzip;
@@ -36096,6 +36097,33 @@ function fromBase64Url(str) {
while (str.length % 4) str += "=";
return base64.toByteArray(str);
}
var MAX_INFLATED_QR_BYTES = 256 * 1024;
var INFLATE_CHUNK_SIZE = 16 * 1024;
function inflateBounded(compressed, label) {
const inflator = new Inflate_1({ chunkSize: INFLATE_CHUNK_SIZE });
const chunks = [];
let total = 0;
inflator.onData = (chunk) => {
total += chunk.length;
if (total > MAX_INFLATED_QR_BYTES) {
throw new Error(
`QR payload expands beyond the ${MAX_INFLATED_QR_BYTES / 1024} KB limit (${label})`
);
}
chunks.push(chunk);
};
inflator.push(compressed, true);
if (inflator.err) {
throw new Error(`QR payload could not be decompressed (${label}): ${inflator.msg || inflator.err}`);
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
function generateUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
@@ -36221,7 +36249,7 @@ async function receiveAndProcess(qrStrings, recipientEcdhPrivKey = null, trusted
try {
const encoded = pack.jsonObj;
const compressed = fromBase64Url(encoded.body || encoded);
const cborBytes = inflate_1(compressed);
const cborBytes = inflateBounded(compressed, "primary");
console.log("\u{1F513} Decompressed CBOR bytes length:", cborBytes.length);
console.log("\u{1F513} CBOR bytes type:", typeof cborBytes, cborBytes.constructor.name);
const cborArrayBuffer = cborBytes.buffer.slice(cborBytes.byteOffset, cborBytes.byteOffset + cborBytes.byteLength);
@@ -36308,7 +36336,7 @@ async function receiveAndProcess(qrStrings, recipientEcdhPrivKey = null, trusted
const originalBody = encoded.body || encoded;
console.log("\u{1F513} Trying to decode original body:", originalBody.substring(0, 50) + "...");
const compressed2 = fromBase64Url(originalBody);
const decompressed = inflate_1(compressed2);
const decompressed = inflateBounded(compressed2, "fallback");
console.log("\u{1F513} Decompressed length:", decompressed.length);
const decompressedArrayBuffer = decompressed.buffer.slice(decompressed.byteOffset, decompressed.byteOffset + decompressed.byteLength);
const cborDecoded = cbor.decode(decompressedArrayBuffer);
+2 -2
View File
File diff suppressed because one or more lines are too long