feat(crypto): Double Ratchet forward secrecy; hardening pass; release v5.7.1
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:
@@ -10,3 +10,11 @@ npm-debug.log*
|
|||||||
# Operator ICE override holds TURN credentials — never commit it.
|
# Operator ICE override holds TURN credentials — never commit it.
|
||||||
# Use config/ice-servers.example.js as the template.
|
# Use config/ice-servers.example.js as the template.
|
||||||
config/ice-servers.js
|
config/ice-servers.js
|
||||||
|
|
||||||
|
# Internal security review notes. These describe attack paths against specific
|
||||||
|
# releases in enough detail to reproduce them, which is useful in private and
|
||||||
|
# harmful in a public repository — users who have not updated yet would be the
|
||||||
|
# ones exposed. Keep them out of the tree.
|
||||||
|
SECURITY_AUDIT.md
|
||||||
|
SECURITY_AUDIT*.md
|
||||||
|
audit/
|
||||||
|
|||||||
+138
@@ -1,5 +1,143 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v5.7.1 — Forward secrecy now engages for both sides of a chat
|
||||||
|
|
||||||
|
The Double Ratchet introduced in 5.7.0 was only taking effect for the peer who
|
||||||
|
joined a conversation; the peer who created the invitation stayed on the
|
||||||
|
previous per-session key scheme. Both sides now negotiate and run it, so a
|
||||||
|
conversation is protected symmetrically end to end.
|
||||||
|
|
||||||
|
If you installed 5.7.0, updating is worthwhile — it is what makes per-message
|
||||||
|
forward secrecy apply to your whole conversation rather than one direction of it.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- The ratchet's test suites now construct the peer's public key exactly as the
|
||||||
|
handshake delivers it (exported and re-imported, non-extractable) rather than
|
||||||
|
reusing a locally generated one. Locally generated public keys are always
|
||||||
|
extractable in WebCrypto, so the earlier tests exercised a key shape the app
|
||||||
|
never actually produces.
|
||||||
|
|
||||||
|
## v5.7.0 — Double Ratchet: forward secrecy for every message
|
||||||
|
|
||||||
|
Sessions previously derived one set of keys during the handshake and used them
|
||||||
|
for the whole conversation. This release adds the Double Ratchet (Signal's
|
||||||
|
design) on top of that, so protection no longer rests on a single set of keys
|
||||||
|
lasting the entire chat.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A separate key for every message.** Each message key is derived from a chain
|
||||||
|
key through a one-way function and discarded immediately after use, so keys
|
||||||
|
that exist now cannot be used to reconstruct earlier ones.
|
||||||
|
- **A Diffie–Hellman step on every change of direction.** Each reply introduces a
|
||||||
|
fresh ECDH key pair and mixes a new shared secret into the root key. A session
|
||||||
|
therefore re-keys itself continuously as the conversation goes back and forth.
|
||||||
|
- **Bounded handling of out-of-order messages.** Keys for messages that have not
|
||||||
|
arrived yet are held so they can still be read, with firm limits on how many
|
||||||
|
are kept (512 per chain, 1024 in total, expiring after five minutes) and a
|
||||||
|
fixed ceiling on how far ahead a message number may jump.
|
||||||
|
|
||||||
|
The ratchet required no change to the handshake. Both peers already hold each
|
||||||
|
other's authenticated ECDH public key, and the safety code compared during
|
||||||
|
verification covers exactly those keys. The ratchet's root is derived from the
|
||||||
|
existing shared secret through its own branch of the key schedule, keeping it
|
||||||
|
separate from the session's other keys.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
Support is advertised in the invitation and the response and used only when both
|
||||||
|
sides have it. A peer on an earlier release negotiates it away and the session
|
||||||
|
runs on the previous scheme — with no server in the design there is no way to
|
||||||
|
update both ends at once, and connecting with the earlier protection is better
|
||||||
|
than not connecting. The security panel shows which of the two is actually in
|
||||||
|
use, rather than what the client is capable of.
|
||||||
|
|
||||||
|
One behaviour worth knowing: the peer who joins has no sending chain until the
|
||||||
|
inviting peer's first message arrives — that is inherent to the ratchet, since
|
||||||
|
both sides derive it from the same exchange. The app sends a presence update from
|
||||||
|
both sides as soon as verification completes, so those first frames use the
|
||||||
|
session keys and everything afterwards is ratcheted.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Connection setup on restrictive networks.** Gathering network candidates only
|
||||||
|
finishes once every configured STUN/TURN server has replied or timed out, which
|
||||||
|
behind a VPN or a strict firewall may not happen at all. Setup now proceeds as
|
||||||
|
soon as there are usable candidates and only keeps waiting while there are
|
||||||
|
none, up to a longer ceiling. A network that genuinely yields nothing now
|
||||||
|
explains what to try instead of failing without explanation.
|
||||||
|
|
||||||
|
## v5.6.2 — Restore connectivity after the 5.6.1 key-handling change
|
||||||
|
|
||||||
|
5.6.1 changed how the shared secret is handled in memory and missed a matching
|
||||||
|
adjustment to key generation, which prevented sessions from being established.
|
||||||
|
Anyone on 5.6.1 should update.
|
||||||
|
|
||||||
|
Key agreement is unchanged on the wire, so 5.6.0 sessions remain compatible.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- Added an end-to-end test that drives the real key generator and derivation
|
||||||
|
rather than constructing its own keys, which is what allowed the mismatch
|
||||||
|
through.
|
||||||
|
|
||||||
|
## v5.6.1 — Hardening pass
|
||||||
|
|
||||||
|
A review of the client produced a set of improvements to how the session is
|
||||||
|
verified, how peer input is handled and what the app stores. Updating is
|
||||||
|
recommended.
|
||||||
|
|
||||||
|
### Improved — verification and peer input
|
||||||
|
|
||||||
|
- **The safety-code comparison is now the only route to a verified session.**
|
||||||
|
Verification state is set in exactly one place, and the checks that guard it
|
||||||
|
cannot be reached around.
|
||||||
|
- **Control messages are honoured only after verification.** Reconnection
|
||||||
|
signalling, call setup, message deletion and delivery receipts all wait until
|
||||||
|
both people have compared the safety code. The verification exchange itself
|
||||||
|
continues to work beforehand, as it must.
|
||||||
|
- **A single path for incoming chat content.** An older, weaker inbound code path
|
||||||
|
was retired so that everything shown in a conversation has been authenticated.
|
||||||
|
|
||||||
|
### Improved — accuracy of what the app reports
|
||||||
|
|
||||||
|
- **The security panel now measures what it displays.** Several checks previously
|
||||||
|
reported a fixed result; they now exercise the subsystem they describe and can
|
||||||
|
report a failure. As a result the score reflects the session more precisely,
|
||||||
|
and may read lower than before on the same connection.
|
||||||
|
- **Forward-secrecy reporting matches reality.** In 5.6.1 the panel reported the
|
||||||
|
session-level guarantee accurately rather than implying per-message protection;
|
||||||
|
5.7.0 adds the per-message protection itself.
|
||||||
|
- **Clearer memory-handling semantics.** Operations that cannot clear a value in
|
||||||
|
JavaScript — immutable strings, non-extractable keys — now say so instead of
|
||||||
|
reporting success.
|
||||||
|
|
||||||
|
### Improved — what stays on the device
|
||||||
|
|
||||||
|
- **Invitation data is no longer kept in local storage.** An unused
|
||||||
|
reference-based QR path wrote session invitation details to local storage
|
||||||
|
without removing them; the path has been removed and existing entries are
|
||||||
|
cleared on first launch after updating.
|
||||||
|
- **Ephemeral messages stay ephemeral.** View-once and disappearing messages no
|
||||||
|
longer place their text in system notifications, where the operating system
|
||||||
|
would retain it beyond the app's control. Ordinary messages are unchanged.
|
||||||
|
|
||||||
|
### Improved — hardening
|
||||||
|
|
||||||
|
- **Shared-secret handling in memory.** The value is derived into a buffer that is
|
||||||
|
overwritten once it is no longer needed.
|
||||||
|
- **Scanned QR codes are decompressed with a size limit,** so a malformed or
|
||||||
|
hostile code cannot exhaust memory.
|
||||||
|
- **Voice notes are validated before being accepted automatically.** Only genuine
|
||||||
|
audio types within a size limit skip the consent prompt; anything else goes
|
||||||
|
through the normal confirmation, which also bounds how much a peer can send
|
||||||
|
unattended.
|
||||||
|
- **The master-password prompt now comes from the app's own interface** rather
|
||||||
|
than a browser dialog.
|
||||||
|
- **Clearer handling of DTLS fingerprints,** with the local and remote values kept
|
||||||
|
separate and reported accurately.
|
||||||
|
|
||||||
## v5.6.0 — Survive a dropped connection
|
## v5.6.0 — Survive a dropped connection
|
||||||
|
|
||||||
A chat no longer dies when the network moves under it. Switching Wi-Fi → LTE,
|
A chat no longer dies when the network moves under it. Switching Wi-Fi → LTE,
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
No accounts. No servers storing your messages. No installation required.
|
No accounts. No servers storing your messages. No installation required.
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](#install-as-an-app)
|
[](#install-as-an-app)
|
||||||
[](#security-model)
|
[](#security-model)
|
||||||
|
[](#forward-secrecy)
|
||||||
|
|
||||||
[Features](#features) · [How it works](#how-it-works) · [Security](#security-model) · [Quick start](#quick-start) · [Documentation](#documentation)
|
[Features](#features) · [How it works](#how-it-works) · [Security](#security-model) · [Quick start](#quick-start) · [Documentation](#documentation)
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ It is designed for people who need a small, auditable, zero-infrastructure way t
|
|||||||
|
|
||||||
** Encryption & verification**
|
** Encryption & verification**
|
||||||
- ECDH P-384 key agreement with derived per-session keys, AES-256-GCM payloads, and DTLS-protected transport.
|
- ECDH P-384 key agreement with derived per-session keys, AES-256-GCM payloads, and DTLS-protected transport.
|
||||||
|
- **Double Ratchet forward secrecy** — every message is encrypted with its own key, and the session re-keys itself each time the conversation changes direction. See [Forward secrecy](#forward-secrecy).
|
||||||
- Interactive **Short Authentication String (SAS)** verification — you confirm a code out-of-band before the session is trusted, defeating man-in-the-middle attacks.
|
- Interactive **Short Authentication String (SAS)** verification — you confirm a code out-of-band before the session is trusted, defeating man-in-the-middle attacks.
|
||||||
- Replay protection, message integrity (HMAC), and a live security report you can open at any time during a call.
|
- Replay protection, message integrity (HMAC), and a live security report you can open at any time during a call.
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ SecureBit never sees your conversation. A session is built directly between the
|
|||||||
| Layer | Mechanism |
|
| Layer | Mechanism |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Key agreement | ECDH (P-384), per-session derived keys |
|
| Key agreement | ECDH (P-384), per-session derived keys |
|
||||||
|
| Forward secrecy | Double Ratchet — per-message keys, DH re-key on each reply |
|
||||||
| Transport | WebRTC data channel over DTLS |
|
| Transport | WebRTC data channel over DTLS |
|
||||||
| Message encryption | AES-256-GCM, end-to-end |
|
| Message encryption | AES-256-GCM, end-to-end |
|
||||||
| Authentication | Interactive SAS bound to both peers' DTLS fingerprints |
|
| Authentication | Interactive SAS bound to both peers' DTLS fingerprints |
|
||||||
@@ -102,7 +105,17 @@ SecureBit never sees your conversation. A session is built directly between the
|
|||||||
| Sanitization | DOMPurify text-only rendering boundary |
|
| Sanitization | DOMPurify text-only rendering boundary |
|
||||||
| Local storage | Encrypted key metadata in IndexedDB |
|
| Local storage | Encrypted key metadata in IndexedDB |
|
||||||
|
|
||||||
A session is **not** treated as verified until both peers complete the SAS flow. This is the step that protects you against a man-in-the-middle: the code must be compared through a channel an attacker cannot impersonate.
|
A session is **not** treated as verified until both peers complete the SAS flow. This is the step that protects you against a man-in-the-middle: the code must be compared through a channel an attacker cannot impersonate. Until it is completed, the session will not act on control messages from the peer.
|
||||||
|
|
||||||
|
### Forward secrecy
|
||||||
|
|
||||||
|
Message protection does not rest on the keys agreed during the handshake. On top of them SecureBit runs the **Double Ratchet** — the design used by Signal:
|
||||||
|
|
||||||
|
- **Every message gets its own key.** It is derived from a chain key through a one-way function and discarded as soon as the message is encrypted or read, so keys held now cannot reconstruct earlier ones. Recovering the live state of a session does not expose what was said before.
|
||||||
|
- **Each change of direction re-keys the session.** Every reply introduces a fresh ECDH key pair and mixes a new shared secret into the root key, so the conversation continuously moves away from any state an attacker may have captured.
|
||||||
|
- **Out-of-order messages are handled within fixed bounds.** Keys are held for messages that have not arrived yet, capped at 512 per chain and 1024 in total and expiring after five minutes, with a limit on how far ahead a message may claim to be.
|
||||||
|
|
||||||
|
The ratchet is negotiated during the handshake and used when both peers support it. If one side is on an older release, the session falls back to per-session keys and the security panel reports which of the two is actually in use — it shows the state of your connection, not the capabilities of your client.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> SecureBit.chat is privacy software, not a guarantee. View-once and disappearing messages are cooperative (not screenshot-proof), and a TURN relay can observe both peers' IPs and traffic timing — though never message contents. See [`SECURITY_DISCLAIMER.md`](SECURITY_DISCLAIMER.md).
|
> SecureBit.chat is privacy software, not a guarantee. View-once and disappearing messages are cooperative (not screenshot-proof), and a TURN relay can observe both peers' IPs and traffic timing — though never message contents. See [`SECURITY_DISCLAIMER.md`](SECURITY_DISCLAIMER.md).
|
||||||
@@ -162,9 +175,10 @@ npm run dev # build and serve locally
|
|||||||
```text
|
```text
|
||||||
src/network/ WebRTC connection and session lifecycle
|
src/network/ WebRTC connection and session lifecycle
|
||||||
src/transfer/ secure file-transfer implementation
|
src/transfer/ secure file-transfer implementation
|
||||||
src/crypto/ cryptographic utilities
|
src/crypto/ cryptographic utilities and the Double Ratchet
|
||||||
src/components/ React UI components
|
src/components/ React UI components
|
||||||
src/styles/ component styles
|
src/styles/ component styles
|
||||||
|
tests/ node:assert suites, run by `npm test`
|
||||||
doc/ technical documentation
|
doc/ technical documentation
|
||||||
dist/ built bundles served in production
|
dist/ built bundles served in production
|
||||||
```
|
```
|
||||||
|
|||||||
Vendored
+1130
-264
File diff suppressed because it is too large
Load Diff
Vendored
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+6
-38
@@ -3906,22 +3906,6 @@ var EnhancedSecureP2PChat = () => {
|
|||||||
return offerData2;
|
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 createTemplateOffer = (offer) => {
|
||||||
const templateOffer = {
|
const templateOffer = {
|
||||||
type: "enhanced_secure_offer_template",
|
type: "enhanced_secure_offer_template",
|
||||||
@@ -4494,28 +4478,12 @@ var EnhancedSecureP2PChat = () => {
|
|||||||
}]);
|
}]);
|
||||||
setShowQRScannerModal(false);
|
setShowQRScannerModal(false);
|
||||||
return true;
|
return true;
|
||||||
} else if (parsedData.type === "secure_offer_reference" && parsedData.referenceId) {
|
} else if (parsedData.type === "secure_offer_reference") {
|
||||||
const fullOfferData = localStorage.getItem(`qr_offer_${parsedData.referenceId}`);
|
setMessages((prev) => [...prev, {
|
||||||
if (fullOfferData) {
|
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.",
|
||||||
const fullOffer = JSON.parse(fullOfferData);
|
type: "error"
|
||||||
if (showOfferStep) {
|
}]);
|
||||||
setAnswerInput(JSON.stringify(fullOffer, null, 2));
|
return false;
|
||||||
} 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 {
|
} else {
|
||||||
if (!parsedData.sdp && parsedData.type === "enhanced_secure_offer") {
|
if (!parsedData.sdp && parsedData.type === "enhanced_secure_offer") {
|
||||||
setMessages((prev) => [...prev, {
|
setMessages((prev) => [...prev, {
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+30
-2
@@ -36078,6 +36078,7 @@ var { Deflate, deflate, deflateRaw, gzip } = deflate_1$1;
|
|||||||
var { Inflate, inflate, inflateRaw, ungzip } = inflate_1$1;
|
var { Inflate, inflate, inflateRaw, ungzip } = inflate_1$1;
|
||||||
var deflate_1 = deflate;
|
var deflate_1 = deflate;
|
||||||
var gzip_1 = gzip;
|
var gzip_1 = gzip;
|
||||||
|
var Inflate_1 = Inflate;
|
||||||
var inflate_1 = inflate;
|
var inflate_1 = inflate;
|
||||||
var ungzip_1 = ungzip;
|
var ungzip_1 = ungzip;
|
||||||
|
|
||||||
@@ -36096,6 +36097,33 @@ function fromBase64Url(str) {
|
|||||||
while (str.length % 4) str += "=";
|
while (str.length % 4) str += "=";
|
||||||
return base64.toByteArray(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() {
|
function generateUUID() {
|
||||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||||
const r = Math.random() * 16 | 0;
|
const r = Math.random() * 16 | 0;
|
||||||
@@ -36221,7 +36249,7 @@ async function receiveAndProcess(qrStrings, recipientEcdhPrivKey = null, trusted
|
|||||||
try {
|
try {
|
||||||
const encoded = pack.jsonObj;
|
const encoded = pack.jsonObj;
|
||||||
const compressed = fromBase64Url(encoded.body || encoded);
|
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} Decompressed CBOR bytes length:", cborBytes.length);
|
||||||
console.log("\u{1F513} CBOR bytes type:", typeof cborBytes, cborBytes.constructor.name);
|
console.log("\u{1F513} CBOR bytes type:", typeof cborBytes, cborBytes.constructor.name);
|
||||||
const cborArrayBuffer = cborBytes.buffer.slice(cborBytes.byteOffset, cborBytes.byteOffset + cborBytes.byteLength);
|
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;
|
const originalBody = encoded.body || encoded;
|
||||||
console.log("\u{1F513} Trying to decode original body:", originalBody.substring(0, 50) + "...");
|
console.log("\u{1F513} Trying to decode original body:", originalBody.substring(0, 50) + "...");
|
||||||
const compressed2 = fromBase64Url(originalBody);
|
const compressed2 = fromBase64Url(originalBody);
|
||||||
const decompressed = inflate_1(compressed2);
|
const decompressed = inflateBounded(compressed2, "fallback");
|
||||||
console.log("\u{1F513} Decompressed length:", decompressed.length);
|
console.log("\u{1F513} Decompressed length:", decompressed.length);
|
||||||
const decompressedArrayBuffer = decompressed.buffer.slice(decompressed.byteOffset, decompressed.byteOffset + decompressed.byteLength);
|
const decompressedArrayBuffer = decompressed.buffer.slice(decompressed.byteOffset, decompressed.byteOffset + decompressed.byteLength);
|
||||||
const cborDecoded = cbor.decode(decompressedArrayBuffer);
|
const cborDecoded = cbor.decode(decompressedArrayBuffer);
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+27
@@ -6,8 +6,30 @@
|
|||||||
|
|
||||||
- `confirmVerification(userCode)` validates a manually entered SAS code.
|
- `confirmVerification(userCode)` validates a manually entered SAS code.
|
||||||
- Verification succeeds only after both local and remote confirmations are present.
|
- Verification succeeds only after both local and remote confirmations are present.
|
||||||
|
- `isVerified` is assigned in one place (`_setVerifiedStatus`), which refuses any
|
||||||
|
SAS-based transition without a recorded local confirmation.
|
||||||
|
- Control frames listed in `POST_VERIFICATION_CONTROL_TYPES` (reconnection
|
||||||
|
signalling, call setup, message deletion, delivery receipts) are only acted on
|
||||||
|
after verification. The set is an allowlist; unrecognised frame types are
|
||||||
|
rejected by the chat channel's default-deny branch.
|
||||||
- Protocol version `4.1` is enforced during offer/answer processing.
|
- Protocol version `4.1` is enforced during offer/answer processing.
|
||||||
|
|
||||||
|
### Forward secrecy
|
||||||
|
|
||||||
|
- `isRatchetActive()` reports whether the Double Ratchet is running on this
|
||||||
|
connection. It is negotiated: both peers advertise `RATCHET_VERSION` in the
|
||||||
|
offer and answer, and a peer that does not falls back to per-session keys.
|
||||||
|
- `_ratchet.canEncrypt` is false on the joining peer until the inviting peer's
|
||||||
|
first message arrives — the sending chain does not exist until then. Callers
|
||||||
|
must check it rather than assume; the send path falls back to session keys for
|
||||||
|
those first frames.
|
||||||
|
- `_ratchet.getState()` returns counters and the number of retained keys for
|
||||||
|
diagnostics. It exposes no key material.
|
||||||
|
- Ratcheted chat arrives as `MESSAGE_TYPES.RATCHET_MESSAGE` with `h` (the header
|
||||||
|
string, used verbatim as AES-GCM additional data) and `c` (base64 body). The
|
||||||
|
header must be passed back to `decrypt()` exactly as received; re-serialising
|
||||||
|
it can change a byte and fail authentication.
|
||||||
|
|
||||||
### Privacy mode
|
### Privacy mode
|
||||||
|
|
||||||
- relay-only configuration sets WebRTC `iceTransportPolicy` to `"relay"`.
|
- relay-only configuration sets WebRTC `iceTransportPolicy` to `"relay"`.
|
||||||
@@ -27,6 +49,11 @@
|
|||||||
- `onProgress` receives `{ fileId, uiId, direction, progress, isVoice, voice }`.
|
- `onProgress` receives `{ fileId, uiId, direction, progress, isVoice, voice }`.
|
||||||
`onIncomingFileRequest` and `onReceived` include `isVoice` and `voice` so the UI
|
`onIncomingFileRequest` and `onReceived` include `isVoice` and `voice` so the UI
|
||||||
can auto-accept and render a voice bubble instead of a file card.
|
can auto-accept and render a voice bubble instead of a file card.
|
||||||
|
- The `isVoice` a callback receives is the **receiver's** verdict, not the
|
||||||
|
sender's claim: `validateIncomingMetadata` clears it unless the transfer
|
||||||
|
declares a recognised audio MIME type and fits the per-note and per-session
|
||||||
|
size budgets. A transfer that fails those checks is not rejected — it simply
|
||||||
|
loses the consent-free shortcut and is offered as a normal file.
|
||||||
|
|
||||||
## EnhancedSecureFileTransfer
|
## EnhancedSecureFileTransfer
|
||||||
|
|
||||||
|
|||||||
+54
-4
@@ -2,20 +2,66 @@
|
|||||||
|
|
||||||
## Release context
|
## Release context
|
||||||
|
|
||||||
- Product release: `v5.4.5`
|
- Product release: `v5.7.1`
|
||||||
- Protocol version: `4.1`
|
- Protocol version: `4.1`
|
||||||
|
- Ratchet wire version: `1`
|
||||||
|
|
||||||
## Session establishment
|
## Session establishment
|
||||||
|
|
||||||
SecureBit.chat uses ECDH-derived session material, DTLS-protected WebRTC transport, and a mandatory Short Authentication String (SAS) verification step.
|
SecureBit.chat uses ECDH-derived session material, DTLS-protected WebRTC transport, and a mandatory Short Authentication String (SAS) verification step.
|
||||||
|
|
||||||
The SAS is deterministic for both peers in the same authenticated session. Users compare the displayed code through an out-of-band channel and enter the matching code manually. Local success alone is insufficient: the session becomes verified only after both peers confirm.
|
The SAS is deterministic for both peers in the same authenticated session: it is derived with HKDF from the ECDH-derived key fingerprint together with both peers' DTLS fingerprints, canonicalised so each side computes the same value. Users compare the displayed code through an out-of-band channel and enter the matching code manually. Local success alone is insufficient: the session becomes verified only after both peers confirm.
|
||||||
|
|
||||||
|
Verification is the gate for the session, not a label on it. Until both peers have confirmed, the connection does not act on control messages from the other side — reconnection signalling, call setup, message deletion and delivery receipts all wait. The verification exchange itself is the deliberate exception, since it necessarily runs first.
|
||||||
|
|
||||||
|
## Key schedule
|
||||||
|
|
||||||
|
A single ECDH exchange produces the session's root material. From it, HKDF-SHA256 derives four independent keys plus the ratchet root, each under its own `info` label so that compromise of one reveals nothing about the others:
|
||||||
|
|
||||||
|
| Derived key | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `message-encryption-v4` | AES-256-GCM payload key (static path) |
|
||||||
|
| `message-authentication-v4` | HMAC-SHA256 message authentication |
|
||||||
|
| `metadata-protection-v4` | AES-256-GCM for message metadata |
|
||||||
|
| `fingerprint-generation-v4` | Key fingerprint shown to the user and fed to the SAS |
|
||||||
|
| `double-ratchet-root-v1` | Root key for the Double Ratchet |
|
||||||
|
|
||||||
|
The raw ECDH output is derived with `deriveBits`, used as HKDF input material, and the buffer holding it is overwritten as soon as the derivation completes. Session keys themselves are non-extractable `CryptoKey` handles.
|
||||||
|
|
||||||
|
## Forward secrecy — the Double Ratchet
|
||||||
|
|
||||||
|
Message protection does not rest on the keys agreed during the handshake. On top of them the client runs the Double Ratchet (Signal's design), implemented in `src/crypto/DoubleRatchet.js`.
|
||||||
|
|
||||||
|
**Symmetric ratchet.** Each message key is derived from a chain key with `KDF_CK` (HMAC-SHA256 over the chain key with distinct constants for the message key and the next chain key), then discarded after a single use. The construction is one-way, so possession of the current chain key does not yield any earlier message key.
|
||||||
|
|
||||||
|
**DH ratchet.** Each time the conversation changes direction, the replying peer introduces a fresh ECDH key pair and both sides mix a new shared secret into the root key with `KDF_RK` (HKDF-SHA256, root key as salt). A session therefore re-keys continuously as messages go back and forth.
|
||||||
|
|
||||||
|
**Initialisation.** No additional handshake data is exchanged. Both peers already hold each other's authenticated ECDH public key — the same keys the SAS covers — so the inviting peer begins with a fresh ratchet key against the peer's handshake key, and the joining peer begins with its own handshake key pair. The first DH step converges on the same secret from both directions.
|
||||||
|
|
||||||
|
The joining peer has no sending chain until the inviting peer's first message arrives; this is inherent to the ratchet, since both sides derive it from the same exchange. Frames sent before that point use the session keys.
|
||||||
|
|
||||||
|
**Message framing.** Each ratcheted frame carries a header — the sender's current ratchet public key, the length of the previous sending chain and the message number in the current one. The header is transmitted in the clear, because the receiver needs it before it can derive a key, and is passed to AES-GCM as additional authenticated data. Any modification to it causes decryption to fail rather than redirecting the ratchet.
|
||||||
|
|
||||||
|
**Out-of-order messages.** Keys for messages that have not yet arrived are retained so they can still be read, within fixed bounds:
|
||||||
|
|
||||||
|
| Bound | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Maximum skip within one chain | 512 |
|
||||||
|
| Total retained keys | 1024 (oldest evicted first) |
|
||||||
|
| Retention period | 5 minutes |
|
||||||
|
|
||||||
|
These are a resource control, not a tuning parameter: the message number is supplied by the peer, so the jump a single frame may claim has to be limited.
|
||||||
|
|
||||||
|
**State changes are applied only after authentication.** Receiving stages the chain advance and any DH step, attempts decryption, and commits only on success. A frame that fails authentication leaves the ratchet untouched, so a malformed or forged frame cannot desynchronise an established session.
|
||||||
|
|
||||||
|
**Negotiation.** Support is advertised in the invitation and in the response, and the ratchet is used only when both sides advertise it. A peer on an earlier release negotiates it away and the session runs on the per-session keys described above. The security panel reports which of the two is in force for the current connection.
|
||||||
|
|
||||||
## Message protection
|
## Message protection
|
||||||
|
|
||||||
- encrypted payloads are validated before decryption
|
- encrypted payloads are validated before decryption
|
||||||
|
- chat content reaches the interface through one authenticated path only; unauthenticated frames are rejected rather than rendered
|
||||||
- decrypted chat text is sanitized before entering React state or the UI
|
- decrypted chat text is sanitized before entering React state or the UI
|
||||||
- replay and ordering controls remain part of the session layer
|
- replay and ordering controls remain part of the session layer; on the ratcheted path replay protection is intrinsic, since a message key is destroyed on use
|
||||||
- voice messages are transported over the file-transfer channel: each is
|
- voice messages are transported over the file-transfer channel: each is
|
||||||
encrypted with a per-file AES-GCM session key and integrity-checked with a
|
encrypted with a per-file AES-GCM session key and integrity-checked with a
|
||||||
signed SHA-256 hash before playback
|
signed SHA-256 hash before playback
|
||||||
@@ -24,6 +70,10 @@ The SAS is deterministic for both peers in the same authenticated session. Users
|
|||||||
|
|
||||||
Sensitive IndexedDB metadata is stored in encrypted envelopes. Legacy plaintext metadata remains readable through a migration path and is re-written in encrypted form when accessed. Corrupted encrypted metadata fails closed.
|
Sensitive IndexedDB metadata is stored in encrypted envelopes. Legacy plaintext metadata remains readable through a migration path and is re-written in encrypted form when accessed. Corrupted encrypted metadata fails closed.
|
||||||
|
|
||||||
|
## Memory handling
|
||||||
|
|
||||||
|
Values that can be overwritten are overwritten: the ECDH output, HKDF intermediates, ratchet root and chain keys, and retained message keys are all zeroed when no longer needed. Values that cannot be overwritten in JavaScript — immutable strings, and non-extractable `CryptoKey` handles whose material lives outside the JS heap — are documented as such rather than reported as cleared; for those, non-extractability is the protection.
|
||||||
|
|
||||||
## Scope note
|
## Scope note
|
||||||
|
|
||||||
This document describes the current browser implementation behavior relevant to the v5.4.5 release. It does not replace independent cryptographic review.
|
This document describes the current browser implementation behavior relevant to the v5.7.1 release. It does not replace independent cryptographic review.
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
| Area | Current behavior |
|
| Area | Current behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Protocol | `4.1` with mismatch rejection |
|
| Protocol | `4.1` with mismatch rejection |
|
||||||
| Peer verification | mandatory manual SAS entry |
|
| Peer verification | mandatory manual SAS entry; control messages gated on it |
|
||||||
|
| Forward secrecy | Double Ratchet (wire version `1`), negotiated per session |
|
||||||
| Transport | WebRTC over DTLS |
|
| Transport | WebRTC over DTLS |
|
||||||
| Privacy mode | optional TURN relay-only mode |
|
| Privacy mode | optional TURN relay-only mode |
|
||||||
| Message UI safety | incoming decrypted text sanitized before display |
|
| Message UI safety | one authenticated inbound path; decrypted text sanitized before display |
|
||||||
| File transfer | validated metadata, explicit consent, allowlist policy |
|
| File transfer | validated metadata, explicit consent, allowlist policy |
|
||||||
| Voice messages | same chunked AES-GCM transfer as files; auto-accepted and played inline |
|
| Voice messages | same chunked AES-GCM transfer as files; auto-accepted within audio-type and size limits |
|
||||||
| Local metadata | encrypted IndexedDB envelopes with migration |
|
| Local metadata | encrypted IndexedDB envelopes with migration |
|
||||||
| Lifecycle | unified disconnect cleanup and bounded resource retention |
|
| Lifecycle | unified disconnect cleanup and bounded resource retention |
|
||||||
|
|
||||||
@@ -32,7 +33,31 @@ peer confirmation received
|
|||||||
verified session
|
verified session
|
||||||
```
|
```
|
||||||
|
|
||||||
The verified state is reached only when both local and remote confirmation flags are true.
|
The verified state is reached only when both local and remote confirmation flags are true, and it is set in a single place so the transition cannot be reached by another route.
|
||||||
|
|
||||||
|
Verification is enforced, not merely displayed. Before it completes, the session declines to act on control messages from the peer — reconnection signalling, call setup, message deletion and delivery receipts. Only the verification exchange itself and liveness probes run earlier, because they have to.
|
||||||
|
|
||||||
|
## Message protection layers
|
||||||
|
|
||||||
|
```text
|
||||||
|
ECDH (P-384)
|
||||||
|
↓
|
||||||
|
HKDF key schedule
|
||||||
|
┌─────────┬───────┬──────────┬─────────────┐
|
||||||
|
↓ ↓ ↓ ↓ ↓
|
||||||
|
message MAC metadata fingerprint ratchet root
|
||||||
|
key key key (→ SAS) ↓
|
||||||
|
Double Ratchet
|
||||||
|
per-message keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Chat content is encrypted under a ratchet-derived key when both peers support it, and under the session message key otherwise. Either way it reaches the interface through one authenticated path; frames that fail authentication are dropped rather than displayed.
|
||||||
|
|
||||||
|
## Forward secrecy
|
||||||
|
|
||||||
|
Per-message keys are derived from a chain key by a one-way function and destroyed after use, and each change of direction introduces a fresh ECDH key pair that re-keys the session root. Out-of-order delivery is supported within fixed bounds (512 skipped keys per chain, 1024 retained in total, five-minute expiry), which limits how much state a peer can cause to be held. Incoming frames are authenticated before any ratchet state is committed, so a bad frame cannot desynchronise a live session.
|
||||||
|
|
||||||
|
See [`CRYPTOGRAPHY.md`](CRYPTOGRAPHY.md) for the key schedule and framing details.
|
||||||
|
|
||||||
## File-transfer architecture
|
## File-transfer architecture
|
||||||
|
|
||||||
@@ -55,6 +80,12 @@ regular file:
|
|||||||
3. the receiver **auto-accepts** voice transfers (no consent prompt) and plays
|
3. the receiver **auto-accepts** voice transfers (no consent prompt) and plays
|
||||||
them inline from an in-memory blob — nothing is written to disk
|
them inline from an in-memory blob — nothing is written to disk
|
||||||
|
|
||||||
|
The auto-accept decision belongs to the receiver, not the sender. A transfer
|
||||||
|
qualifies only if it declares a recognised audio MIME type and stays within a
|
||||||
|
4 MB per-note limit and a per-session budget; anything else is handled as an
|
||||||
|
ordinary file and goes through the normal consent prompt. This keeps the
|
||||||
|
convenience of voice notes from becoming an unattended transfer channel.
|
||||||
|
|
||||||
## Disconnect cleanup
|
## Disconnect cleanup
|
||||||
|
|
||||||
The canonical disconnect path clears:
|
The canonical disconnect path clears:
|
||||||
@@ -63,4 +94,6 @@ The canonical disconnect path clears:
|
|||||||
- timers, deferred retries, fake traffic, and decoy traffic
|
- timers, deferred retries, fake traffic, and decoy traffic
|
||||||
- pending transfer state and consent waits
|
- pending transfer state and consent waits
|
||||||
- verification state and crypto/PFS state
|
- verification state and crypto/PFS state
|
||||||
|
- ratchet state: root key, both chain keys and every retained message key are
|
||||||
|
overwritten, not merely dereferenced
|
||||||
- React file-transfer callbacks and stale UI transfer state
|
- React file-transfer callbacks and stale UI transfer state
|
||||||
|
|||||||
+22
-22
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="./manifest.json">
|
<link rel="manifest" href="./manifest.json">
|
||||||
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1785719685129">
|
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1785985047695">
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- PWA Meta Tags -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
@@ -90,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">
|
<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 -->
|
<!-- Apple Touch Icons -->
|
||||||
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1785719685129">
|
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1785985047695">
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
|
<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="60x60" href="./logo/icon-60x60.png">
|
||||||
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
|
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
|
<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="144x144" href="./logo/icon-144x144.png">
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.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=1785719685129">
|
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1785985047695">
|
||||||
|
|
||||||
<!-- Microsoft Tiles -->
|
<!-- Microsoft Tiles -->
|
||||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||||
both execute in document order after parsing, so React still runs before the
|
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. -->
|
app modules below, but the parser / first paint is no longer blocked. -->
|
||||||
<script defer src="config/ice-servers.js?v=1785719685129"></script>
|
<script defer src="config/ice-servers.js?v=1785985047695"></script>
|
||||||
<script defer src="libs/react/react.production.min.js"></script>
|
<script defer src="libs/react/react.production.min.js"></script>
|
||||||
<script defer src="libs/react-dom/react-dom.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 —
|
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||||
@@ -191,8 +191,8 @@
|
|||||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||||
<script defer src="libs/prism/prism.js"></script>
|
<script defer src="libs/prism/prism.js"></script>
|
||||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||||
<link rel="stylesheet" href="assets/tailwind.css?v=1785719685129">
|
<link rel="stylesheet" href="assets/tailwind.css?v=1785985047695">
|
||||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1785719685129">
|
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1785985047695">
|
||||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
<!-- 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
|
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. -->
|
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||||
@@ -200,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-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="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="/assets/fonts/inter/inter.css">
|
||||||
<link rel="stylesheet" href="src/styles/main.css?v=1785719685129">
|
<link rel="stylesheet" href="src/styles/main.css?v=1785985047695">
|
||||||
<link rel="stylesheet" href="src/styles/animations.css?v=1785719685129">
|
<link rel="stylesheet" href="src/styles/animations.css?v=1785985047695">
|
||||||
<link rel="stylesheet" href="src/styles/components.css?v=1785719685129">
|
<link rel="stylesheet" href="src/styles/components.css?v=1785985047695">
|
||||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||||
<script defer src="src/scripts/load-async-css.js?v=1785719685129"></script>
|
<script defer src="src/scripts/load-async-css.js?v=1785985047695"></script>
|
||||||
<noscript>
|
<noscript>
|
||||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||||
<link rel="stylesheet" href="libs/prism/prism.css">
|
<link rel="stylesheet" href="libs/prism/prism.css">
|
||||||
</noscript>
|
</noscript>
|
||||||
<script defer src="src/scripts/fa-check.js?v=1785719685129"></script>
|
<script defer src="src/scripts/fa-check.js?v=1785985047695"></script>
|
||||||
<!-- Update Manager - система принудительного обновления -->
|
<!-- Update Manager - система принудительного обновления -->
|
||||||
<script defer src="src/utils/updateManager.js?v=1785719685129"></script>
|
<script defer src="src/utils/updateManager.js?v=1785985047695"></script>
|
||||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1785719685129"></script>
|
<script type="module" src="src/components/UpdateChecker.jsx?v=1785985047695"></script>
|
||||||
<script type="module" src="dist/qr-local.js?v=1785719685129"></script>
|
<script type="module" src="dist/qr-local.js?v=1785985047695"></script>
|
||||||
<script type="module" src="src/components/QRScanner.js?v=1785719685129"></script>
|
<script type="module" src="src/components/QRScanner.js?v=1785985047695"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="dist/app-boot.js?v=1785719685129"></script>
|
<script type="module" src="dist/app-boot.js?v=1785985047695"></script>
|
||||||
<script type="module" src="dist/app.js?v=1785719685129"></script>
|
<script type="module" src="dist/app.js?v=1785985047695"></script>
|
||||||
|
|
||||||
<script defer src="src/scripts/pwa-register.js?v=1785719685129"></script>
|
<script defer src="src/scripts/pwa-register.js?v=1785985047695"></script>
|
||||||
<script src="./src/pwa/install-prompt.js?v=1785719685129" type="module"></script>
|
<script src="./src/pwa/install-prompt.js?v=1785985047695" type="module"></script>
|
||||||
<script src="./src/pwa/pwa-manager.js?v=1785719685129" type="module"></script>
|
<script src="./src/pwa/pwa-manager.js?v=1785985047695" type="module"></script>
|
||||||
<script defer src="./src/scripts/pwa-offline-test.js?v=1785719685129"></script>
|
<script defer src="./src/scripts/pwa-offline-test.js?v=1785985047695"></script>
|
||||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1785719685129">
|
<link rel="stylesheet" href="./src/styles/pwa.css?v=1785985047695">
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"version": "1785719685129",
|
"version": "1785985047695",
|
||||||
"buildVersion": "1785719685129",
|
"buildVersion": "1785985047695",
|
||||||
"appVersion": "5.6.0",
|
"appVersion": "5.7.1",
|
||||||
"buildTime": "2026-08-03T01:14:45.169Z",
|
"buildTime": "2026-08-06T02:57:27.737Z",
|
||||||
"buildId": "1785719685129-60bf037",
|
"buildId": "1785985047695-2a7142c",
|
||||||
"gitHash": "60bf037",
|
"gitHash": "2a7142c",
|
||||||
"generated": true,
|
"generated": true,
|
||||||
"generatedAt": "2026-08-03T01:14:45.171Z"
|
"generatedAt": "2026-08-06T02:57:27.739Z"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "securebit-chat",
|
"name": "securebit-chat",
|
||||||
"version": "5.6.0",
|
"version": "5.7.1",
|
||||||
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
||||||
"main": "index.html",
|
"main": "index.html",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"dev": "npm run build && python -m http.server 8000",
|
"dev": "npm run build && python -m http.server 8000",
|
||||||
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
||||||
"serve": "npx http-server -p 8000",
|
"serve": "npx http-server -p 8000",
|
||||||
"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/desktop-download-links.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 && node tests/session-recovery.test.mjs"
|
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.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/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.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 && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"p2p",
|
"p2p",
|
||||||
|
|||||||
+18
-49
@@ -3895,28 +3895,12 @@ import {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createQRReference = (offerData) => {
|
// NOTE: createQRReference() used to live here. It kept the invitation
|
||||||
try {
|
// payload in localStorage and put only a reference id in the QR — which
|
||||||
// Create a unique reference ID for this offer
|
// could not work across devices, since the payload stayed on the
|
||||||
const referenceId = `offer_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
// sender's machine, and left records behind that nothing cleaned up.
|
||||||
|
// Removed rather than repaired; QR invitations travel as
|
||||||
// Store the full offer data in localStorage with the reference ID
|
// self-contained COSE payloads (see packSecurePayload).
|
||||||
localStorage.setItem(`qr_offer_${referenceId}`, JSON.stringify(offerData));
|
|
||||||
|
|
||||||
// Create a minimal QR code with just the reference
|
|
||||||
const qrReference = {
|
|
||||||
type: 'secure_offer_reference',
|
|
||||||
referenceId: 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 createTemplateOffer = (offer) => {
|
||||||
// Minimal template to keep QR within single image capacity
|
// Minimal template to keep QR within single image capacity
|
||||||
@@ -4550,33 +4534,18 @@ import {
|
|||||||
setShowQRScannerModal(false); // Close QR scanner modal
|
setShowQRScannerModal(false); // Close QR scanner modal
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Check if this is a reference-based QR code
|
// Reference-based QR codes are no longer produced or read:
|
||||||
else if (parsedData.type === 'secure_offer_reference' && parsedData.referenceId) {
|
// the payload they pointed at lived in the *sender's*
|
||||||
// Try to get the full offer data from localStorage
|
// localStorage, so a scan on the peer's device never had
|
||||||
const fullOfferData = localStorage.getItem(`qr_offer_${parsedData.referenceId}`);
|
// anything to resolve, and the records leaked the session's
|
||||||
if (fullOfferData) {
|
// SDP, keys and SAS code onto disk forever. See the note
|
||||||
const fullOffer = JSON.parse(fullOfferData);
|
// where createQRReference used to be.
|
||||||
// Determine which input to populate based on current mode
|
else if (parsedData.type === 'secure_offer_reference') {
|
||||||
if (showOfferStep) {
|
setMessages(prev => [...prev, {
|
||||||
// In "Waiting for peer's response" mode - populate answerInput
|
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.',
|
||||||
setAnswerInput(JSON.stringify(fullOffer, null, 2));
|
type: 'error'
|
||||||
} else {
|
}]);
|
||||||
// In "Paste secure invitation" mode - populate offerInput
|
return false;
|
||||||
setOfferInput(JSON.stringify(fullOffer, null, 2));
|
|
||||||
}
|
|
||||||
setMessages(prev => [...prev, {
|
|
||||||
message: '📱 QR code scanned successfully! Full offer data retrieved.',
|
|
||||||
type: 'success'
|
|
||||||
}]);
|
|
||||||
setShowQRScannerModal(false); // Close QR scanner modal
|
|
||||||
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 {
|
} else {
|
||||||
// If payload was compressed, it's already decompressed above; keep legacy warning only when clearly incomplete
|
// If payload was compressed, it's already decompressed above; keep legacy warning only when clearly incomplete
|
||||||
if (!parsedData.sdp && parsedData.type === 'enhanced_secure_offer') {
|
if (!parsedData.sdp && parsedData.type === 'enhanced_secure_offer') {
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
/**
|
||||||
|
* Double Ratchet (Signal specification) for SecureBit.chat.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ---------------
|
||||||
|
* Until now the session derived one set of keys from a single ECDH at handshake
|
||||||
|
* time and used them for the whole conversation. `rotateKeys()` was written but
|
||||||
|
* never called and `keyRotationInterval` was null, so a key recovered at any
|
||||||
|
* point — from a heap snapshot, a compromised extension, a seized device with
|
||||||
|
* the tab still open — decrypted every message ever sent in that session,
|
||||||
|
* including messages sent hours earlier. Forward secrecy existed only BETWEEN
|
||||||
|
* sessions.
|
||||||
|
*
|
||||||
|
* The Double Ratchet fixes both halves of that:
|
||||||
|
*
|
||||||
|
* - Symmetric ratchet: each message gets its own key, derived from a chain key
|
||||||
|
* by a one-way KDF. The message key is destroyed after use and the chain key
|
||||||
|
* is replaced by its successor, so a key captured now cannot reproduce any
|
||||||
|
* earlier one. That is forward secrecy per message.
|
||||||
|
*
|
||||||
|
* - DH ratchet: each time the conversation changes direction, the replying
|
||||||
|
* side introduces a fresh ECDH key pair and both sides mix a new shared
|
||||||
|
* secret into the root key. An attacker who learns the entire state is
|
||||||
|
* locked out again as soon as one message is exchanged in each direction.
|
||||||
|
* That is post-compromise security, which no amount of symmetric ratcheting
|
||||||
|
* provides.
|
||||||
|
*
|
||||||
|
* INITIALISATION WITHOUT A HANDSHAKE CHANGE
|
||||||
|
* -----------------------------------------
|
||||||
|
* Signal bootstraps from a pre-key bundle. We do not need one: both peers
|
||||||
|
* already hold each other's authenticated ECDH public key from the existing
|
||||||
|
* handshake, and the SAS the user compared covers exactly those keys. So the
|
||||||
|
* initiator starts with a fresh ratchet key against the peer's handshake key,
|
||||||
|
* and the responder starts with its own handshake key pair as its ratchet pair.
|
||||||
|
* The first DH ratchet then agrees on the same secret from both directions.
|
||||||
|
* Nothing new has to be sent during setup, which keeps the change confined to
|
||||||
|
* the message path.
|
||||||
|
*
|
||||||
|
* WHAT IS AUTHENTICATED
|
||||||
|
* ---------------------
|
||||||
|
* The header (ratchet public key, previous chain length, message number) travels
|
||||||
|
* in the clear — the receiver needs it before it can derive a key — but it is
|
||||||
|
* passed to AES-GCM as additional authenticated data. Tampering with any header
|
||||||
|
* field makes decryption fail rather than silently redirecting the ratchet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT_INFO = 'SecureBit-DR-Root-v1';
|
||||||
|
const MESSAGE_INFO = 'SecureBit-DR-Message-v1';
|
||||||
|
const INIT_INFO = 'SecureBit-DR-Init-v1';
|
||||||
|
|
||||||
|
// Chain-key advance constants, per the Signal spec's KDF_CK.
|
||||||
|
const MK_SEED = Uint8Array.of(0x01);
|
||||||
|
const CK_SEED = Uint8Array.of(0x02);
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounds on out-of-order tolerance. These are a DoS control, not a tuning knob:
|
||||||
|
* every skipped message forces us to derive and RETAIN a key, so an attacker who
|
||||||
|
* can pick message numbers would otherwise make us allocate without limit by
|
||||||
|
* sending n = 2^31 once.
|
||||||
|
*/
|
||||||
|
export const RATCHET_LIMITS = Object.freeze({
|
||||||
|
// How far ahead of the expected number a single message may jump.
|
||||||
|
MAX_SKIP_PER_CHAIN: 512,
|
||||||
|
// Total retained keys for messages that never arrived, across all chains.
|
||||||
|
MAX_SKIPPED_KEYS: 1024,
|
||||||
|
// Retained keys older than this are dropped: the data channel is reliable
|
||||||
|
// and ordered, so a gap that has not resolved in minutes never will.
|
||||||
|
SKIPPED_KEY_TTL_MS: 5 * 60 * 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
function b64(bytes) {
|
||||||
|
let binary = '';
|
||||||
|
const view = new Uint8Array(bytes);
|
||||||
|
for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unb64(text) {
|
||||||
|
const binary = atob(text);
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeroize(bytes) {
|
||||||
|
try {
|
||||||
|
if (bytes && bytes.length) {
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
bytes.fill(0);
|
||||||
|
}
|
||||||
|
} catch (_) { /* detached buffer — already unreadable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hkdf(ikm, salt, info, lengthBytes) {
|
||||||
|
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{ name: 'HKDF', hash: 'SHA-256', salt, info: enc.encode(info) },
|
||||||
|
key,
|
||||||
|
lengthBytes * 8
|
||||||
|
);
|
||||||
|
return new Uint8Array(bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmac(keyBytes, data) {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
||||||
|
);
|
||||||
|
return new Uint8Array(await crypto.subtle.sign('HMAC', key, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KDF_CK — advance a chain and emit this message's key. One-way by construction. */
|
||||||
|
async function advanceChain(chainKey) {
|
||||||
|
const messageKey = await hmac(chainKey, MK_SEED);
|
||||||
|
const nextChainKey = await hmac(chainKey, CK_SEED);
|
||||||
|
return { messageKey, nextChainKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KDF_RK — mix a fresh DH secret into the root key, yielding the next chain. */
|
||||||
|
async function advanceRoot(rootKey, dhOutput) {
|
||||||
|
const derived = await hkdf(dhOutput, rootKey, ROOT_INFO, 64);
|
||||||
|
const nextRoot = derived.slice(0, 32);
|
||||||
|
const chainKey = derived.slice(32, 64);
|
||||||
|
zeroize(derived);
|
||||||
|
return { nextRoot, chainKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DoubleRatchet {
|
||||||
|
constructor() {
|
||||||
|
this._rootKey = null;
|
||||||
|
this._sendingChainKey = null;
|
||||||
|
this._receivingChainKey = null;
|
||||||
|
this._selfKeyPair = null; // DHs
|
||||||
|
this._remotePublicKey = null; // DHr
|
||||||
|
this._remotePublicKeyB64 = null;
|
||||||
|
this._sendCount = 0; // Ns
|
||||||
|
this._receiveCount = 0; // Nr
|
||||||
|
this._previousSendCount = 0; // PN
|
||||||
|
this._skipped = new Map(); // "<dhB64>|<n>" -> { key, storedAt }
|
||||||
|
this._namedCurve = 'P-384';
|
||||||
|
this._initialised = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {Uint8Array} options.sharedSecret ECDH output from the handshake.
|
||||||
|
* @param {Uint8Array} options.sessionSalt The session's 64-byte salt.
|
||||||
|
* @param {CryptoKey} options.selfPrivateKey Our handshake ECDH private key.
|
||||||
|
* @param {CryptoKey} options.remotePublicKey Peer's handshake ECDH public key.
|
||||||
|
* @param {boolean} options.isInitiator True for the side that created the offer.
|
||||||
|
*/
|
||||||
|
async init({ sharedSecret, sessionSalt, selfPrivateKey, remotePublicKey, isInitiator }) {
|
||||||
|
if (!(sharedSecret instanceof Uint8Array) || sharedSecret.length === 0) {
|
||||||
|
throw new Error('DoubleRatchet: a shared secret is required');
|
||||||
|
}
|
||||||
|
if (!(selfPrivateKey instanceof CryptoKey) || !(remotePublicKey instanceof CryptoKey)) {
|
||||||
|
throw new Error('DoubleRatchet: handshake ECDH keys are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._namedCurve = selfPrivateKey.algorithm?.namedCurve || 'P-384';
|
||||||
|
|
||||||
|
// The root key is bound to the session salt, so two sessions between the
|
||||||
|
// same pair of long-term keys never share ratchet state.
|
||||||
|
this._rootKey = await hkdf(sharedSecret, sessionSalt ?? new Uint8Array(0), INIT_INFO, 32);
|
||||||
|
|
||||||
|
if (isInitiator) {
|
||||||
|
// Send first: adopt a fresh ratchet key immediately and step the root
|
||||||
|
// once, so the very first message already leaves the handshake key
|
||||||
|
// behind.
|
||||||
|
this._selfKeyPair = await this._generateKeyPair();
|
||||||
|
|
||||||
|
// Deliberately NOT exported to base64 here. The peer's handshake
|
||||||
|
// public key arrives via importSignedPublicKey, which imports it as
|
||||||
|
// NON-EXTRACTABLE — exporting it throws InvalidAccessError and would
|
||||||
|
// abort ratchet setup on the initiator only, silently downgrading it
|
||||||
|
// to static keys while the responder ran fine. The b64 form exists
|
||||||
|
// solely to recognise a changed ratchet key on inbound messages, and
|
||||||
|
// there are none yet: leaving it null makes the peer's first message
|
||||||
|
// (which carries their own fresh ratchet key) correctly read as a new
|
||||||
|
// chain and trigger the DH ratchet.
|
||||||
|
this._remotePublicKey = remotePublicKey;
|
||||||
|
this._remotePublicKeyB64 = null;
|
||||||
|
|
||||||
|
const dh = await this._dh(this._selfKeyPair.privateKey, this._remotePublicKey);
|
||||||
|
const { nextRoot, chainKey } = await advanceRoot(this._rootKey, dh);
|
||||||
|
zeroize(dh);
|
||||||
|
zeroize(this._rootKey);
|
||||||
|
this._rootKey = nextRoot;
|
||||||
|
this._sendingChainKey = chainKey;
|
||||||
|
} else {
|
||||||
|
// Receive first: keep the handshake key pair as the current ratchet
|
||||||
|
// pair so the initiator's first DH lands on a key we hold, and take
|
||||||
|
// no chain until that message arrives.
|
||||||
|
this._selfKeyPair = { privateKey: selfPrivateKey, publicKey: null };
|
||||||
|
this._remotePublicKey = null;
|
||||||
|
this._remotePublicKeyB64 = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._initialised = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isInitialised() { return this._initialised; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* False on the responder until the initiator's first message arrives.
|
||||||
|
*
|
||||||
|
* This is inherent to the Double Ratchet, not an implementation gap: the
|
||||||
|
* responder's sending chain is only defined once it has seen the initiator's
|
||||||
|
* ratchet key, because both sides must derive it from the same DH. Callers
|
||||||
|
* have to check this rather than assume, or the responder's first message —
|
||||||
|
* which the app sends automatically as a presence update the moment
|
||||||
|
* verification completes — throws instead of going out.
|
||||||
|
*/
|
||||||
|
get canEncrypt() {
|
||||||
|
return this._initialised && this._sendingChainKey !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diagnostics only — deliberately exposes no key material. */
|
||||||
|
getState() {
|
||||||
|
return {
|
||||||
|
initialised: this._initialised,
|
||||||
|
sending: this._sendingChainKey !== null,
|
||||||
|
receiving: this._receivingChainKey !== null,
|
||||||
|
sendCount: this._sendCount,
|
||||||
|
receiveCount: this._receiveCount,
|
||||||
|
previousSendCount: this._previousSendCount,
|
||||||
|
skippedKeys: this._skipped.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _generateKeyPair() {
|
||||||
|
return crypto.subtle.generateKey(
|
||||||
|
{ name: 'ECDH', namedCurve: this._namedCurve },
|
||||||
|
false,
|
||||||
|
['deriveKey', 'deriveBits']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _dh(privateKey, publicKey) {
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{ name: 'ECDH', public: publicKey }, privateKey, 256
|
||||||
|
);
|
||||||
|
return new Uint8Array(bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _selfPublicKeyB64() {
|
||||||
|
if (!this._selfKeyPair?.publicKey) return null;
|
||||||
|
return b64(await crypto.subtle.exportKey('spki', this._selfKeyPair.publicKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async _importPublic(spkiB64) {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
'spki', unb64(spkiB64), { name: 'ECDH', namedCurve: this._namedCurve }, true, []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive the AES-GCM key and IV for one message, then forget the message key. */
|
||||||
|
async _messageCipher(messageKey) {
|
||||||
|
const material = await hkdf(messageKey, new Uint8Array(32), MESSAGE_INFO, 44);
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw', material.slice(0, 32), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
const iv = material.slice(32, 44);
|
||||||
|
zeroize(material);
|
||||||
|
return { key, iv };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} plaintext
|
||||||
|
* @returns {Promise<{header: string, ciphertext: string}>} header is the exact
|
||||||
|
* string that must be transmitted and fed back to decrypt(): it doubles as
|
||||||
|
* the AAD, so re-serialising it on the far side could change a byte and
|
||||||
|
* fail authentication for no reason.
|
||||||
|
*/
|
||||||
|
async encrypt(plaintext) {
|
||||||
|
if (!this._initialised) throw new Error('DoubleRatchet: not initialised');
|
||||||
|
if (!this._sendingChainKey) {
|
||||||
|
throw new Error('DoubleRatchet: no sending chain — awaiting the peer\'s first message');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageKey, nextChainKey } = await advanceChain(this._sendingChainKey);
|
||||||
|
zeroize(this._sendingChainKey);
|
||||||
|
this._sendingChainKey = nextChainKey;
|
||||||
|
|
||||||
|
const header = JSON.stringify({
|
||||||
|
dh: await this._selfPublicKeyB64(),
|
||||||
|
pn: this._previousSendCount,
|
||||||
|
n: this._sendCount
|
||||||
|
});
|
||||||
|
this._sendCount += 1;
|
||||||
|
|
||||||
|
const { key, iv } = await this._messageCipher(messageKey);
|
||||||
|
zeroize(messageKey);
|
||||||
|
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv, additionalData: enc.encode(header) },
|
||||||
|
key,
|
||||||
|
enc.encode(plaintext)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { header, ciphertext: b64(ciphertext) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} header Exactly the string produced by encrypt().
|
||||||
|
* @param {string} ciphertext Base64 body.
|
||||||
|
* @returns {Promise<string>} plaintext
|
||||||
|
*/
|
||||||
|
async decrypt(header, ciphertext) {
|
||||||
|
if (!this._initialised) throw new Error('DoubleRatchet: not initialised');
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(header);
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error('DoubleRatchet: malformed header');
|
||||||
|
}
|
||||||
|
const { dh, pn, n } = parsed;
|
||||||
|
if (typeof dh !== 'string' || !Number.isSafeInteger(n) || n < 0 ||
|
||||||
|
!Number.isSafeInteger(pn) || pn < 0) {
|
||||||
|
throw new Error('DoubleRatchet: invalid header fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._pruneSkipped();
|
||||||
|
|
||||||
|
// A key retained for a message that arrived late. Only drop it once the
|
||||||
|
// message actually opens: a forged frame quoting a real header must not
|
||||||
|
// consume the key that the genuine message still needs.
|
||||||
|
const skippedId = `${dh}|${n}`;
|
||||||
|
const retained = this._skipped.get(skippedId);
|
||||||
|
if (retained) {
|
||||||
|
const plaintext = await this._open(retained.key, header, ciphertext);
|
||||||
|
this._skipped.delete(skippedId);
|
||||||
|
zeroize(retained.key);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY / ROBUSTNESS: everything below is staged and only committed
|
||||||
|
// once the message authenticates. The header is attacker-reachable — it
|
||||||
|
// travels in the clear so the receiver can route on it — and mutating the
|
||||||
|
// ratchet before verifying would let one forged or corrupted frame
|
||||||
|
// advance our chains past the peer's, desynchronising the session
|
||||||
|
// permanently. AES-GCM covers the header as AAD, so a bad frame is
|
||||||
|
// detected; it must simply leave no trace when it is.
|
||||||
|
const staged = await this._stageReceive(dh, pn, n);
|
||||||
|
|
||||||
|
let plaintext;
|
||||||
|
try {
|
||||||
|
plaintext = await this._open(staged.messageKey, header, ciphertext);
|
||||||
|
} catch (error) {
|
||||||
|
staged.discard();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
staged.commit();
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work out which key opens this message and what the resulting state would
|
||||||
|
* be, without touching `this`. Returns the candidate key plus commit/discard.
|
||||||
|
*/
|
||||||
|
async _stageReceive(dh, pn, n) {
|
||||||
|
const isNewChain = dh !== this._remotePublicKeyB64;
|
||||||
|
const pending = []; // skipped keys to retain on commit
|
||||||
|
const toZeroOnCommit = []; // superseded chain keys
|
||||||
|
let ratchet = null;
|
||||||
|
|
||||||
|
let chainKey;
|
||||||
|
let receiveCount;
|
||||||
|
let remoteB64;
|
||||||
|
|
||||||
|
if (isNewChain) {
|
||||||
|
// Messages still missing from the OLD chain, before it is replaced.
|
||||||
|
if (this._receivingChainKey) {
|
||||||
|
const carried = await this._collectSkipped(
|
||||||
|
this._receivingChainKey, this._receiveCount, pn, this._remotePublicKeyB64
|
||||||
|
);
|
||||||
|
pending.push(...carried.keys);
|
||||||
|
toZeroOnCommit.push(carried.finalChainKey);
|
||||||
|
}
|
||||||
|
ratchet = await this._stageDhRatchet(dh);
|
||||||
|
chainKey = ratchet.receivingChainKey;
|
||||||
|
receiveCount = 0;
|
||||||
|
remoteB64 = dh;
|
||||||
|
} else {
|
||||||
|
chainKey = this._receivingChainKey;
|
||||||
|
receiveCount = this._receiveCount;
|
||||||
|
remoteB64 = this._remotePublicKeyB64;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chainKey) {
|
||||||
|
throw new Error('DoubleRatchet: no receiving chain for this message');
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = await this._collectSkipped(chainKey, receiveCount, n, remoteB64);
|
||||||
|
pending.push(...gap.keys);
|
||||||
|
|
||||||
|
const { messageKey, nextChainKey } = await advanceChain(gap.finalChainKey);
|
||||||
|
if (gap.finalChainKey !== chainKey) toZeroOnCommit.push(gap.finalChainKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageKey,
|
||||||
|
commit: () => {
|
||||||
|
if (ratchet) ratchet.apply();
|
||||||
|
if (this._receivingChainKey && this._receivingChainKey !== nextChainKey) {
|
||||||
|
zeroize(this._receivingChainKey);
|
||||||
|
}
|
||||||
|
for (const key of toZeroOnCommit) zeroize(key);
|
||||||
|
this._receivingChainKey = nextChainKey;
|
||||||
|
this._receiveCount = n + 1;
|
||||||
|
this._remotePublicKeyB64 = remoteB64;
|
||||||
|
for (const { id, key } of pending) this._rememberSkipped(id, key);
|
||||||
|
zeroize(messageKey);
|
||||||
|
},
|
||||||
|
discard: () => {
|
||||||
|
if (ratchet) ratchet.discard();
|
||||||
|
for (const { key } of pending) zeroize(key);
|
||||||
|
for (const key of toZeroOnCommit) zeroize(key);
|
||||||
|
zeroize(nextChainKey);
|
||||||
|
zeroize(messageKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the keys for messages `from`..`until-1` without mutating state.
|
||||||
|
* `until` comes off the wire, so the jump is bounded here rather than trusted.
|
||||||
|
*/
|
||||||
|
async _collectSkipped(chainKey, from, until, remoteB64) {
|
||||||
|
if (until < from) {
|
||||||
|
// An older number on a chain we have already advanced past: either a
|
||||||
|
// replay or a duplicate. Its key is gone, so it cannot be opened.
|
||||||
|
throw new Error('DoubleRatchet: message number is behind the current chain');
|
||||||
|
}
|
||||||
|
if (until - from > RATCHET_LIMITS.MAX_SKIP_PER_CHAIN) {
|
||||||
|
throw new Error(
|
||||||
|
`DoubleRatchet: refusing to skip ${until - from} messages ` +
|
||||||
|
`(limit ${RATCHET_LIMITS.MAX_SKIP_PER_CHAIN})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = [];
|
||||||
|
let current = chainKey;
|
||||||
|
for (let i = from; i < until; i++) {
|
||||||
|
const { messageKey, nextChainKey } = await advanceChain(current);
|
||||||
|
if (current !== chainKey) zeroize(current);
|
||||||
|
current = nextChainKey;
|
||||||
|
keys.push({ id: `${remoteB64}|${i}`, key: messageKey });
|
||||||
|
}
|
||||||
|
return { keys, finalChainKey: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _open(messageKey, header, ciphertext) {
|
||||||
|
const { key, iv } = await this._messageCipher(messageKey);
|
||||||
|
let opened;
|
||||||
|
try {
|
||||||
|
opened = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv, additionalData: enc.encode(header) },
|
||||||
|
key,
|
||||||
|
unb64(ciphertext)
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Wrong key, tampered body, or a tampered header — AES-GCM cannot
|
||||||
|
// tell us which, and neither should we: the answer is the same.
|
||||||
|
throw new Error('DoubleRatchet: authentication failed');
|
||||||
|
}
|
||||||
|
return dec.decode(opened);
|
||||||
|
}
|
||||||
|
|
||||||
|
_rememberSkipped(id, key) {
|
||||||
|
// Oldest-first eviction keeps the cache bounded even if every gap is
|
||||||
|
// legitimate; losing the oldest gap is preferable to unbounded growth.
|
||||||
|
while (this._skipped.size >= RATCHET_LIMITS.MAX_SKIPPED_KEYS) {
|
||||||
|
const oldest = this._skipped.keys().next().value;
|
||||||
|
const evicted = this._skipped.get(oldest);
|
||||||
|
this._skipped.delete(oldest);
|
||||||
|
if (evicted) zeroize(evicted.key);
|
||||||
|
}
|
||||||
|
this._skipped.set(id, { key, storedAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
_pruneSkipped() {
|
||||||
|
const cutoff = Date.now() - RATCHET_LIMITS.SKIPPED_KEY_TTL_MS;
|
||||||
|
for (const [id, entry] of this._skipped) {
|
||||||
|
if (entry.storedAt < cutoff) {
|
||||||
|
zeroize(entry.key);
|
||||||
|
this._skipped.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the DH-ratchet step without applying it. The caller applies it only
|
||||||
|
* after the triggering message has authenticated — see _stageReceive.
|
||||||
|
*/
|
||||||
|
async _stageDhRatchet(remotePublicKeyB64) {
|
||||||
|
const remotePublicKey = await this._importPublic(remotePublicKeyB64);
|
||||||
|
|
||||||
|
// Receiving chain: our CURRENT key pair against their new key. For the
|
||||||
|
// responder's first ratchet this is still the handshake key pair, which
|
||||||
|
// is exactly what the initiator derived against at init.
|
||||||
|
const receiveDh = await this._dh(this._selfKeyPair.privateKey, remotePublicKey);
|
||||||
|
const received = await advanceRoot(this._rootKey, receiveDh);
|
||||||
|
zeroize(receiveDh);
|
||||||
|
|
||||||
|
// Sending chain: a fresh key pair, so our next message moves the ratchet
|
||||||
|
// on again. This is the step that locks out an attacker who captured the
|
||||||
|
// previous state — without it there is no post-compromise security.
|
||||||
|
const nextSelfKeyPair = await this._generateKeyPair();
|
||||||
|
const sendDh = await this._dh(nextSelfKeyPair.privateKey, remotePublicKey);
|
||||||
|
const sending = await advanceRoot(received.nextRoot, sendDh);
|
||||||
|
zeroize(sendDh);
|
||||||
|
|
||||||
|
return {
|
||||||
|
receivingChainKey: received.chainKey,
|
||||||
|
apply: () => {
|
||||||
|
zeroize(this._rootKey);
|
||||||
|
zeroize(received.nextRoot);
|
||||||
|
if (this._sendingChainKey) zeroize(this._sendingChainKey);
|
||||||
|
this._rootKey = sending.nextRoot;
|
||||||
|
this._sendingChainKey = sending.chainKey;
|
||||||
|
this._selfKeyPair = nextSelfKeyPair;
|
||||||
|
this._remotePublicKey = remotePublicKey;
|
||||||
|
this._remotePublicKeyB64 = remotePublicKeyB64;
|
||||||
|
this._previousSendCount = this._sendCount;
|
||||||
|
this._sendCount = 0;
|
||||||
|
},
|
||||||
|
discard: () => {
|
||||||
|
zeroize(received.nextRoot);
|
||||||
|
zeroize(received.chainKey);
|
||||||
|
zeroize(sending.nextRoot);
|
||||||
|
zeroize(sending.chainKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroy every piece of key material this object holds. */
|
||||||
|
destroy() {
|
||||||
|
zeroize(this._rootKey);
|
||||||
|
zeroize(this._sendingChainKey);
|
||||||
|
zeroize(this._receivingChainKey);
|
||||||
|
for (const entry of this._skipped.values()) zeroize(entry.key);
|
||||||
|
this._skipped.clear();
|
||||||
|
this._rootKey = null;
|
||||||
|
this._sendingChainKey = null;
|
||||||
|
this._receivingChainKey = null;
|
||||||
|
this._selfKeyPair = null;
|
||||||
|
this._remotePublicKey = null;
|
||||||
|
this._remotePublicKeyB64 = null;
|
||||||
|
this._initialised = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,35 @@ class EnhancedSecureCryptoUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite a buffer holding key material once it is no longer needed.
|
||||||
|
*
|
||||||
|
* This is a genuine wipe, unlike the manager's _secureWipeString /
|
||||||
|
* _secureWipeCryptoKey, which cannot wipe anything (JS strings are immutable
|
||||||
|
* and a non-extractable CryptoKey has no JS-visible bytes) and only ever
|
||||||
|
* dropped a reference while reporting success. Here the bytes really are
|
||||||
|
* ours: overwrite them so the shared secret does not linger in the heap
|
||||||
|
* waiting for a garbage collector that may never run before a heap snapshot
|
||||||
|
* or a memory-reading extension gets there first.
|
||||||
|
*
|
||||||
|
* Random first, then zeros: on the off chance a copying GC has already moved
|
||||||
|
* the buffer, the random pass at least destroys the plaintext value at the
|
||||||
|
* old address as well as the new one.
|
||||||
|
*/
|
||||||
|
static zeroizeBuffer(buffer) {
|
||||||
|
try {
|
||||||
|
if (!buffer) return;
|
||||||
|
const view = buffer instanceof Uint8Array
|
||||||
|
? buffer
|
||||||
|
: (buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : null);
|
||||||
|
if (!view || view.length === 0) return;
|
||||||
|
crypto.getRandomValues(view);
|
||||||
|
view.fill(0);
|
||||||
|
} catch (_) {
|
||||||
|
// A detached buffer is already unreadable; nothing left to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async encryptData(data, password) {
|
static async encryptData(data, password) {
|
||||||
try {
|
try {
|
||||||
const dataString = typeof data === 'string' ? data : JSON.stringify(data);
|
const dataString = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
@@ -593,34 +622,113 @@ class EnhancedSecureCryptoUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional verification functions
|
// Additional verification functions.
|
||||||
|
//
|
||||||
|
// These used to be three `return { passed: true }` stubs — a quarter of the
|
||||||
|
// reported score awarded for checks that never ran, under a UI that calls the
|
||||||
|
// result "Real cryptographic tests". A security indicator that cannot fail
|
||||||
|
// tells the user nothing; worse, it keeps reading green after the subsystem
|
||||||
|
// it claims to measure breaks. Each one below now exercises the thing it
|
||||||
|
// names and is expected to be able to fail.
|
||||||
|
|
||||||
static async verifyRateLimiting(securityManager) {
|
static async verifyRateLimiting(securityManager) {
|
||||||
try {
|
try {
|
||||||
// Rate limiting is always available in this implementation
|
const limiter = EnhancedSecureCryptoUtils.rateLimiter;
|
||||||
return { passed: true, details: 'Rate limiting is active and working' };
|
if (!limiter || typeof limiter.checkMessageRate !== 'function') {
|
||||||
|
return { passed: false, details: 'Rate limiter is not available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive a throwaway bucket past its limit and confirm it actually
|
||||||
|
// refuses. A separate identifier per run keeps the live counters
|
||||||
|
// untouched, so running the report never costs the user quota.
|
||||||
|
const probeId = `selftest_${crypto.getRandomValues(new Uint32Array(1))[0]}`;
|
||||||
|
const limit = 3;
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const allowed = await limiter.checkMessageRate(probeId, limit, 60000);
|
||||||
|
if (!allowed) {
|
||||||
|
return { passed: false, details: `Rate limiter refused message ${i + 1} of ${limit} while under the limit` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldBeBlocked = await limiter.checkMessageRate(probeId, limit, 60000);
|
||||||
|
limiter.messages.delete(`msg_${probeId}`);
|
||||||
|
|
||||||
|
if (shouldBeBlocked) {
|
||||||
|
return { passed: false, details: 'Rate limiter did not block a message over the limit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: true, details: `Rate limiting verified: ${limit} allowed, the next refused` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { passed: false, details: `Rate limiting test failed: ${error.message}` };
|
return { passed: false, details: `Rate limiting test failed: ${error.message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async verifyMetadataProtection(securityManager) {
|
static async verifyMetadataProtection(securityManager) {
|
||||||
try {
|
try {
|
||||||
// Metadata protection is always enabled in this implementation
|
const metadataKey = securityManager?.metadataKey;
|
||||||
return { passed: true, details: 'Metadata protection is working correctly' };
|
if (!metadataKey || !(metadataKey instanceof CryptoKey)) {
|
||||||
|
return { passed: false, details: 'Metadata encryption key not available' };
|
||||||
|
}
|
||||||
|
if (metadataKey.algorithm?.name !== 'AES-GCM') {
|
||||||
|
return { passed: false, details: `Metadata key has the wrong algorithm: ${metadataKey.algorithm?.name}` };
|
||||||
|
}
|
||||||
|
if (metadataKey.extractable) {
|
||||||
|
return { passed: false, details: 'Metadata key is extractable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key separation is the whole point: message metadata (ids, sequence
|
||||||
|
// numbers, real lengths) must not be readable with the message key.
|
||||||
|
if (securityManager.encryptionKey === metadataKey) {
|
||||||
|
return { passed: false, details: 'Metadata key is not separated from the message key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip a probe so a key that exists but cannot be used is caught.
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const probe = new TextEncoder().encode('metadata-protection-selftest');
|
||||||
|
const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, metadataKey, probe);
|
||||||
|
const opened = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, metadataKey, sealed);
|
||||||
|
if (new TextDecoder().decode(opened) !== 'metadata-protection-selftest') {
|
||||||
|
return { passed: false, details: 'Metadata encryption round-trip mismatch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: true, details: 'Metadata is encrypted under a separate non-extractable key' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { passed: false, details: `Metadata protection test failed: ${error.message}` };
|
return { passed: false, details: `Metadata protection test failed: ${error.message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async verifyPerfectForwardSecrecy(securityManager) {
|
static async verifyPerfectForwardSecrecy(securityManager) {
|
||||||
try {
|
try {
|
||||||
// Perfect Forward Secrecy is always enabled in this implementation
|
// Session-level PFS is real: every session runs a fresh ephemeral ECDH
|
||||||
return { passed: true, details: 'Perfect Forward Secrecy is configured and active' };
|
// and the derived keys are non-extractable and wiped when it ends.
|
||||||
|
const hasEphemeralKeys = !!securityManager?.ecdhKeyPair?.privateKey &&
|
||||||
|
securityManager.ecdhKeyPair.privateKey.extractable === false;
|
||||||
|
if (!hasEphemeralKeys) {
|
||||||
|
return { passed: false, details: 'No non-extractable ephemeral ECDH key pair for this session' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-session forward secrecy comes from the Double Ratchet: a per-
|
||||||
|
// message key derived by a one-way KDF and destroyed after use, plus a
|
||||||
|
// DH step whenever the conversation changes direction. Without it a
|
||||||
|
// single compromised session key opens the entire transcript, which is
|
||||||
|
// the state this check used to report as "configured and active".
|
||||||
|
if (securityManager?.isRatchetActive?.()) {
|
||||||
|
const state = securityManager._ratchet?.getState?.() || {};
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
details: `Double Ratchet active: per-message keys destroyed after use, DH re-key on each reply (sent ${state.sendCount ?? 0}, received ${state.receiveCount ?? 0} on the current chain)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
details: 'Session-level PFS only: keys are ephemeral per session, but the Double Ratchet is not active for this connection (peer on an older version), so a compromised session key exposes the whole conversation'
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { passed: false, details: `PFS test failed: ${error.message}` };
|
return { passed: false, details: `PFS test failed: ${error.message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async verifyReplayProtection(securityManager) {
|
static async verifyReplayProtection(securityManager) {
|
||||||
try {
|
try {
|
||||||
// Debug logs removed to prevent leaking runtime state
|
// Debug logs removed to prevent leaking runtime state
|
||||||
@@ -757,16 +865,29 @@ class EnhancedSecureCryptoUtils {
|
|||||||
|
|
||||||
|
|
||||||
static async verifyNonExtractableKeys(securityManager) {
|
static async verifyNonExtractableKeys(securityManager) {
|
||||||
try {
|
// This check was inverted: it returned true when exportKey SUCCEEDED —
|
||||||
if (!securityManager.encryptionKey) return false;
|
// i.e. when the key was extractable, the failure case — and also true in
|
||||||
|
// the catch. It could not return false, so it confirmed nothing.
|
||||||
// Test if keys are non-extractable
|
const keys = [
|
||||||
const keyData = await crypto.subtle.exportKey('raw', securityManager.encryptionKey);
|
['encryptionKey', securityManager?.encryptionKey],
|
||||||
return keyData && keyData.byteLength > 0;
|
['macKey', securityManager?.macKey],
|
||||||
} catch (error) {
|
['metadataKey', securityManager?.metadataKey]
|
||||||
// If export fails, keys are non-extractable (which is good)
|
];
|
||||||
return true;
|
|
||||||
|
for (const [name, key] of keys) {
|
||||||
|
if (!key || !(key instanceof CryptoKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// `extractable` is the authoritative answer and needs no export
|
||||||
|
// attempt; exporting a key just to prove it cannot be exported would
|
||||||
|
// copy it into the JS heap on every implementation that allows it.
|
||||||
|
if (key.extractable !== false) {
|
||||||
|
EnhancedSecureCryptoUtils.secureLog.log('error', 'Session key is extractable', { keyName: name });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async verifyEnhancedValidation(securityManager) {
|
static async verifyEnhancedValidation(securityManager) {
|
||||||
@@ -1085,15 +1206,22 @@ class EnhancedSecureCryptoUtils {
|
|||||||
namedCurve: 'P-384'
|
namedCurve: 'P-384'
|
||||||
},
|
},
|
||||||
false, // Non-extractable for enhanced security
|
false, // Non-extractable for enhanced security
|
||||||
['deriveKey']
|
// 'deriveBits' is REQUIRED: deriveSharedKeys() uses deriveBits so
|
||||||
|
// the shared secret lands in a buffer we can overwrite, instead of
|
||||||
|
// being exported out of an extractable key and left in the heap.
|
||||||
|
// Without this usage WebCrypto rejects the derivation outright and
|
||||||
|
// no session can be established. Usages are local to the CryptoKey
|
||||||
|
// and are not part of the exported SPKI, so this does not change
|
||||||
|
// anything on the wire.
|
||||||
|
['deriveKey', 'deriveBits']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Removed key generation info logging to avoid exposing key-related metadata
|
// Removed key generation info logging to avoid exposing key-related metadata
|
||||||
|
|
||||||
return keyPair;
|
return keyPair;
|
||||||
} catch (p384Error) {
|
} catch (p384Error) {
|
||||||
EnhancedSecureCryptoUtils.secureLog.log('warn', 'Elliptic curve P-384 generation failed, switching curve', { error: p384Error.message });
|
EnhancedSecureCryptoUtils.secureLog.log('warn', 'Elliptic curve P-384 generation failed, switching curve', { error: p384Error.message });
|
||||||
|
|
||||||
// Fallback to P-256
|
// Fallback to P-256
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
const keyPair = await crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
@@ -1101,7 +1229,7 @@ class EnhancedSecureCryptoUtils {
|
|||||||
namedCurve: 'P-256'
|
namedCurve: 'P-256'
|
||||||
},
|
},
|
||||||
false, // Non-extractable for enhanced security
|
false, // Non-extractable for enhanced security
|
||||||
['deriveKey']
|
['deriveKey', 'deriveBits']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Removed key generation info logging to avoid exposing key-related metadata
|
// Removed key generation info logging to avoid exposing key-related metadata
|
||||||
@@ -1879,49 +2007,62 @@ class EnhancedSecureCryptoUtils {
|
|||||||
const saltBytes = new Uint8Array(salt);
|
const saltBytes = new Uint8Array(salt);
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
// Step 1: Derive raw ECDH shared secret using pure ECDH
|
// Step 1: Derive the raw ECDH shared secret as HKDF input material.
|
||||||
|
//
|
||||||
|
// This used to derive an EXTRACTABLE AES-GCM key and then exportKey()
|
||||||
|
// it, which put the shared secret into an ArrayBuffer that was never
|
||||||
|
// cleared — it simply fell out of scope and sat in the JS heap until
|
||||||
|
// GC, readable by anything with access to the page (a compromised
|
||||||
|
// extension, a heap snapshot in a crash report). Every session key is
|
||||||
|
// derived from those 32 bytes with public salt and hard-coded info
|
||||||
|
// strings, so recovering them recovers the whole session.
|
||||||
|
//
|
||||||
|
// deriveBits gives the same bytes without the detour through an
|
||||||
|
// extractable CryptoKey, and hands back a buffer we own and can wipe.
|
||||||
|
// WIRE COMPATIBILITY: for ECDH, deriveBits(n) returns the leftmost n
|
||||||
|
// bits of the shared X coordinate, which is exactly what deriveKey to
|
||||||
|
// AES-GCM-256 used — so 256 here reproduces the previous bytes exactly
|
||||||
|
// and a 5.6.1 client still interoperates with 5.6.0. Do not "improve"
|
||||||
|
// this to 384 without a protocol version bump.
|
||||||
let rawSharedSecret;
|
let rawSharedSecret;
|
||||||
|
let sharedSecretBits = null;
|
||||||
try {
|
try {
|
||||||
// Removed detailed key derivation logging
|
sharedSecretBits = await crypto.subtle.deriveBits(
|
||||||
|
|
||||||
// Use pure ECDH to derive raw key material
|
|
||||||
const rawKeyMaterial = await crypto.subtle.deriveKey(
|
|
||||||
{
|
{
|
||||||
name: 'ECDH',
|
name: 'ECDH',
|
||||||
public: publicKey
|
public: publicKey
|
||||||
},
|
},
|
||||||
privateKey,
|
privateKey,
|
||||||
{
|
256
|
||||||
name: 'AES-GCM',
|
|
||||||
length: 256
|
|
||||||
},
|
|
||||||
true, // Extractable
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Export the raw key material
|
|
||||||
const rawKeyData = await crypto.subtle.exportKey('raw', rawKeyMaterial);
|
|
||||||
|
|
||||||
// Import as HKDF key material for further derivation
|
|
||||||
rawSharedSecret = await crypto.subtle.importKey(
|
rawSharedSecret = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
rawKeyData,
|
sharedSecretBits,
|
||||||
{
|
{
|
||||||
name: 'HKDF',
|
name: 'HKDF',
|
||||||
hash: 'SHA-256'
|
hash: 'SHA-256'
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
['deriveKey']
|
// deriveBits is required for the fingerprint material below;
|
||||||
|
// without it that call fails with an InvalidAccessError.
|
||||||
|
['deriveKey', 'deriveBits']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Removed detailed key derivation logging
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
EnhancedSecureCryptoUtils.secureLog.log('error', 'ECDH derivation failed', {
|
EnhancedSecureCryptoUtils.secureLog.log('error', 'ECDH derivation failed', {
|
||||||
error: error.message
|
error: error.message
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// importKey copies the material, so the source buffer is dead
|
||||||
|
// weight from here on — overwrite it rather than leaving the
|
||||||
|
// shared secret lying in the heap.
|
||||||
|
if (sharedSecretBits) {
|
||||||
|
EnhancedSecureCryptoUtils.zeroizeBuffer(sharedSecretBits);
|
||||||
|
sharedSecretBits = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Use HKDF to derive specific keys directly
|
// Step 2: Use HKDF to derive specific keys directly
|
||||||
// Removed detailed key derivation logging
|
// Removed detailed key derivation logging
|
||||||
|
|
||||||
@@ -2000,27 +2141,53 @@ class EnhancedSecureCryptoUtils {
|
|||||||
['encrypt', 'decrypt']
|
['encrypt', 'decrypt']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate temporary extractable key for fingerprint calculation
|
// Root key for the Double Ratchet, derived here rather than handing the
|
||||||
let fingerprintKey;
|
// raw ECDH secret to the caller: the secret is wiped before this
|
||||||
fingerprintKey = await crypto.subtle.deriveKey(
|
// function returns (see the finally above), and only this 32-byte
|
||||||
|
// branch of the KDF tree ever leaves. Its own info string keeps it
|
||||||
|
// domain-separated from the message, MAC and metadata keys, so
|
||||||
|
// learning a session key tells an attacker nothing about the ratchet.
|
||||||
|
const ratchetRootBits = await crypto.subtle.deriveBits(
|
||||||
{
|
{
|
||||||
name: 'HKDF',
|
name: 'HKDF',
|
||||||
hash: 'SHA-256',
|
hash: 'SHA-256',
|
||||||
salt: saltBytes,
|
salt: saltBytes,
|
||||||
info: encoder.encode('fingerprint-generation-v4')
|
info: encoder.encode('double-ratchet-root-v1')
|
||||||
},
|
},
|
||||||
rawSharedSecret,
|
rawSharedSecret,
|
||||||
{
|
256
|
||||||
name: 'AES-GCM',
|
|
||||||
length: 256
|
|
||||||
},
|
|
||||||
true, // Extractable only for fingerprint
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
);
|
||||||
|
const ratchetRoot = new Uint8Array(ratchetRootBits);
|
||||||
|
|
||||||
// Generate key fingerprint for verification
|
// Fingerprint material. Previously this derived a second EXTRACTABLE
|
||||||
const fingerprintKeyData = await crypto.subtle.exportKey('raw', fingerprintKey);
|
// AES key purely so it could be exported — leaving another copy of
|
||||||
const fingerprint = await EnhancedSecureCryptoUtils.generateKeyFingerprint(Array.from(new Uint8Array(fingerprintKeyData)));
|
// key-derived material in the heap with nothing wiping it. HKDF can
|
||||||
|
// hand back raw bits directly; same salt, same info, same 256 bits, so
|
||||||
|
// the fingerprint (and therefore the SAS built on it) is unchanged.
|
||||||
|
let fingerprintBits = null;
|
||||||
|
let fingerprint;
|
||||||
|
try {
|
||||||
|
fingerprintBits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: saltBytes,
|
||||||
|
info: encoder.encode('fingerprint-generation-v4')
|
||||||
|
},
|
||||||
|
rawSharedSecret,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
// A Uint8Array view, not Array.from(): the array copy was a third
|
||||||
|
// copy of key-derived bytes in the heap that nothing cleared.
|
||||||
|
fingerprint = await EnhancedSecureCryptoUtils.generateKeyFingerprint(
|
||||||
|
new Uint8Array(fingerprintBits)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (fingerprintBits) {
|
||||||
|
EnhancedSecureCryptoUtils.zeroizeBuffer(fingerprintBits);
|
||||||
|
fingerprintBits = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate that all derived keys are CryptoKey instances
|
// Validate that all derived keys are CryptoKey instances
|
||||||
if (!(messageKey instanceof CryptoKey)) {
|
if (!(messageKey instanceof CryptoKey)) {
|
||||||
@@ -2062,6 +2229,10 @@ class EnhancedSecureCryptoUtils {
|
|||||||
macKey,
|
macKey,
|
||||||
pfsKey, // Added Perfect Forward Secrecy key
|
pfsKey, // Added Perfect Forward Secrecy key
|
||||||
metadataKey,
|
metadataKey,
|
||||||
|
// Raw bytes on purpose: a ratchet has to chain KDFs itself, which
|
||||||
|
// WebCrypto cannot do behind a non-extractable handle. The caller
|
||||||
|
// must hand this to DoubleRatchet.init() and zeroize it.
|
||||||
|
ratchetRoot,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
version: '4.0'
|
version: '4.0'
|
||||||
|
|||||||
+58
-2
@@ -19,6 +19,62 @@ function fromBase64Url(str) {
|
|||||||
return base64.toByteArray(str);
|
return base64.toByteArray(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A scanned QR is fully attacker-controlled input, and DEFLATE compresses
|
||||||
|
// repetitive data by roughly 1000:1 — so a QR small enough to print on a sticker
|
||||||
|
// can expand to hundreds of megabytes and take the tab (or a phone) down with an
|
||||||
|
// out-of-memory kill, taking the session and its keys with it.
|
||||||
|
//
|
||||||
|
// Real offers are single-digit kilobytes; 256 KB leaves generous headroom for a
|
||||||
|
// multi-chunk payload while making the bomb harmless.
|
||||||
|
const MAX_INFLATED_QR_BYTES = 256 * 1024;
|
||||||
|
// pako emits output in chunks of this size, which is also the granularity at
|
||||||
|
// which we can notice we have gone too far. Small enough to abort promptly.
|
||||||
|
const INFLATE_CHUNK_SIZE = 16 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress with a hard ceiling on the OUTPUT size.
|
||||||
|
*
|
||||||
|
* NOTE: pako's documented `maxOutputLength` option is silently ignored by
|
||||||
|
* pako 2.1.0 — passing it still inflates the full stream (verified in
|
||||||
|
* tests/qr-zip-bomb.test.mjs, which fails if a future pako starts honouring it
|
||||||
|
* or if someone reverts to the one-shot helper). The streaming API is what
|
||||||
|
* actually works: throwing from onData aborts mid-stream, so a bomb costs one
|
||||||
|
* chunk past the limit instead of the whole payload.
|
||||||
|
*/
|
||||||
|
function inflateBounded(compressed, label) {
|
||||||
|
const inflator = new pako.Inflate({ 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);
|
||||||
|
};
|
||||||
|
// onEnd is deliberately NOT overridden: pako's default is what assigns
|
||||||
|
// `this.err` / `this.msg`, so replacing it with a no-op silently swallows
|
||||||
|
// every decompression error and makes malformed input look like success.
|
||||||
|
// It only flattens the (now empty) internal chunk list, which costs nothing.
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate UUID for chunking
|
// Generate UUID for chunking
|
||||||
function generateUUID() {
|
function generateUUID() {
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
@@ -205,7 +261,7 @@ export async function receiveAndProcess(qrStrings, recipientEcdhPrivKey = null,
|
|||||||
|
|
||||||
// 2. Decode: base64url -> decompress -> CBOR decode
|
// 2. Decode: base64url -> decompress -> CBOR decode
|
||||||
const compressed = fromBase64Url(encoded.body || encoded);
|
const compressed = fromBase64Url(encoded.body || encoded);
|
||||||
const cborBytes = pako.inflate(compressed);
|
const cborBytes = inflateBounded(compressed, 'primary');
|
||||||
console.log('🔓 Decompressed CBOR bytes length:', cborBytes.length);
|
console.log('🔓 Decompressed CBOR bytes length:', cborBytes.length);
|
||||||
console.log('🔓 CBOR bytes type:', typeof cborBytes, cborBytes.constructor.name);
|
console.log('🔓 CBOR bytes type:', typeof cborBytes, cborBytes.constructor.name);
|
||||||
|
|
||||||
@@ -326,7 +382,7 @@ export async function receiveAndProcess(qrStrings, recipientEcdhPrivKey = null,
|
|||||||
|
|
||||||
// Decode base64url -> decompress -> CBOR decode -> extract JSON
|
// Decode base64url -> decompress -> CBOR decode -> extract JSON
|
||||||
const compressed = fromBase64Url(originalBody);
|
const compressed = fromBase64Url(originalBody);
|
||||||
const decompressed = pako.inflate(compressed);
|
const decompressed = inflateBounded(compressed, 'fallback');
|
||||||
console.log('🔓 Decompressed length:', decompressed.length);
|
console.log('🔓 Decompressed length:', decompressed.length);
|
||||||
|
|
||||||
// Convert to ArrayBuffer for CBOR decoding
|
// Convert to ArrayBuffer for CBOR decoding
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ class NotificationIntegration {
|
|||||||
// IMPORTANT: forward ALL arguments (incl. per-message `meta`) so the app
|
// IMPORTANT: forward ALL arguments (incl. per-message `meta`) so the app
|
||||||
// still receives view-once / disappearing / unsend metadata.
|
// still receives view-once / disappearing / unsend metadata.
|
||||||
this.webrtcManager.onMessage = (message, type, ...rest) => {
|
this.webrtcManager.onMessage = (message, type, ...rest) => {
|
||||||
this.handleIncomingMessage(message, type);
|
this.handleIncomingMessage(message, type, rest[0]);
|
||||||
|
|
||||||
// Call original callback if it exists
|
// Call original callback if it exists
|
||||||
if (this.originalOnMessage) {
|
if (this.originalOnMessage) {
|
||||||
@@ -70,7 +70,7 @@ class NotificationIntegration {
|
|||||||
if (this.webrtcManager.deliverMessageToUI) {
|
if (this.webrtcManager.deliverMessageToUI) {
|
||||||
this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager);
|
this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager);
|
||||||
this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => {
|
this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => {
|
||||||
this.handleIncomingMessage(message, type);
|
this.handleIncomingMessage(message, type, rest[0]);
|
||||||
this.originalDeliverMessageToUI(message, type, ...rest);
|
this.originalDeliverMessageToUI(message, type, ...rest);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ class NotificationIntegration {
|
|||||||
* @param {string} type - Message type
|
* @param {string} type - Message type
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
handleIncomingMessage(message, type) {
|
handleIncomingMessage(message, type, meta) {
|
||||||
try {
|
try {
|
||||||
// Create a unique key for this message to avoid duplicates
|
// Create a unique key for this message to avoid duplicates
|
||||||
const messageKey = `${type}:${typeof message === 'string' ? message : JSON.stringify(message)}`;
|
const messageKey = `${type}:${typeof message === 'string' ? message : JSON.stringify(message)}`;
|
||||||
@@ -121,10 +121,21 @@ class NotificationIntegration {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PRIVACY: a view-once or disappearing message must not be copied into the
|
||||||
|
// OS notification. Notifications are shown only while the tab is in the
|
||||||
|
// background — i.e. typically on a lock screen — and once the OS has the
|
||||||
|
// text it lands in the notification centre, in backups and on the user's
|
||||||
|
// other synced devices. From there the app can no longer delete it, so the
|
||||||
|
// message the UI destroys after 30 seconds outlives itself indefinitely.
|
||||||
|
// Show that something arrived; never what it said.
|
||||||
|
const isEphemeral = !!meta && typeof meta === 'object' &&
|
||||||
|
(meta.once === true || (Number.isFinite(meta.ttl) && meta.ttl > 0));
|
||||||
|
const notificationText = isEphemeral ? 'Sent you a private message' : messageInfo.text;
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
const notificationResult = this.notificationManager.notify(
|
const notificationResult = this.notificationManager.notify(
|
||||||
messageInfo.senderName,
|
messageInfo.senderName,
|
||||||
messageInfo.text,
|
notificationText,
|
||||||
{
|
{
|
||||||
icon: messageInfo.senderAvatar,
|
icon: messageInfo.senderAvatar,
|
||||||
senderId: messageInfo.senderId,
|
senderId: messageInfo.senderId,
|
||||||
|
|||||||
@@ -20,8 +20,29 @@ window.EnhancedSecureWebRTCManager = EnhancedSecureWebRTCManager;
|
|||||||
window.EnhancedSecureFileTransfer = EnhancedSecureFileTransfer;
|
window.EnhancedSecureFileTransfer = EnhancedSecureFileTransfer;
|
||||||
window.NotificationIntegration = NotificationIntegration;
|
window.NotificationIntegration = NotificationIntegration;
|
||||||
|
|
||||||
|
// Earlier releases had an unused QR flow that persisted session invitation data
|
||||||
|
// under `qr_offer_<id>` and never removed it. The writer is gone, but records it
|
||||||
|
// already left on disk are not, and they outlive a disconnect and the in-app
|
||||||
|
// "clear data". Purge them once on startup, so updating actually clears what was
|
||||||
|
// stored rather than only stopping new entries.
|
||||||
|
const purgeLegacyOfferRecords = () => {
|
||||||
|
try {
|
||||||
|
const stale = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith('qr_offer_')) stale.push(key);
|
||||||
|
}
|
||||||
|
for (const key of stale) {
|
||||||
|
try { localStorage.removeItem(key); } catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Private mode / disabled storage: nothing to purge.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Mount application once DOM and modules are ready
|
// Mount application once DOM and modules are ready
|
||||||
const start = () => {
|
const start = () => {
|
||||||
|
purgeLegacyOfferRecords();
|
||||||
if (typeof window.initializeApp === 'function') {
|
if (typeof window.initializeApp === 'function') {
|
||||||
window.initializeApp();
|
window.initializeApp();
|
||||||
} else if (window.DEBUG_MODE) {
|
} else if (window.DEBUG_MODE) {
|
||||||
|
|||||||
@@ -403,6 +403,19 @@ class EnhancedSecureFileTransfer {
|
|||||||
this.incomingTransferChunkLimiters = new Map();
|
this.incomingTransferChunkLimiters = new Map();
|
||||||
this.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE = 30000; // per transfer (~8 MB/s)
|
this.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE = 30000; // per transfer (~8 MB/s)
|
||||||
this.MAX_PENDING_INCOMING_TRANSFERS = 3;
|
this.MAX_PENDING_INCOMING_TRANSFERS = 3;
|
||||||
|
|
||||||
|
// Voice notes are the one transfer accepted without a consent prompt, and
|
||||||
|
// `isVoice` is set by the SENDER (deliberately outside the signed
|
||||||
|
// fileHash, since it is presentation metadata). Anything that skips the
|
||||||
|
// prompt therefore has to qualify on its own properties rather than on
|
||||||
|
// the sender's assertion — see rejectVoiceAutoAcceptReason().
|
||||||
|
// 4 MB is ~5 minutes of Opus at 96 kbps — well past any real voice note,
|
||||||
|
// and a twentieth of what the `voice` type's 20 MB ceiling used to allow.
|
||||||
|
this.MAX_AUTO_ACCEPT_VOICE_SIZE = 4 * 1024 * 1024;
|
||||||
|
// A whole session's worth of auto-accepted audio. Past this the peer can
|
||||||
|
// still send voice notes, they just need the ordinary consent card.
|
||||||
|
this.MAX_AUTO_ACCEPT_VOICE_SESSION_BYTES = 64 * 1024 * 1024;
|
||||||
|
this.autoAcceptedVoiceBytes = 0;
|
||||||
|
|
||||||
// Session key derivation
|
// Session key derivation
|
||||||
this.sessionKeys = new Map(); // fileId -> derived session key
|
this.sessionKeys = new Map(); // fileId -> derived session key
|
||||||
@@ -538,7 +551,44 @@ class EnhancedSecureFileTransfer {
|
|||||||
if (!validation.isValid) errors.push(...validation.errors);
|
if (!validation.isValid) errors.push(...validation.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isValid: errors.length === 0, errors, displayName };
|
// A transfer only keeps its consent-free voice status if it actually looks
|
||||||
|
// like a voice note. Otherwise it stays a normal file and goes through the
|
||||||
|
// consent card — the transfer is not rejected, it just loses the shortcut.
|
||||||
|
const claimsVoice = !!metadata?.isVoice;
|
||||||
|
const voiceRejection = claimsVoice ? this.rejectVoiceAutoAcceptReason(metadata) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
displayName,
|
||||||
|
isVoice: claimsVoice && !voiceRejection,
|
||||||
|
voiceRejection
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a transfer claiming to be a voice note may not skip the consent card.
|
||||||
|
* Returns null when it may. The generic MIME types that validateFile accepts
|
||||||
|
* for ordinary uploads (application/octet-stream and friends) are explicitly
|
||||||
|
* NOT enough here: they are what lets an arbitrary blob wear a `.mp4` name.
|
||||||
|
*/
|
||||||
|
rejectVoiceAutoAcceptReason(metadata) {
|
||||||
|
const mimeType = String(metadata?.fileType || '').toLowerCase();
|
||||||
|
const size = metadata?.fileSize;
|
||||||
|
|
||||||
|
if (!mimeType.startsWith('audio/')) {
|
||||||
|
return `not an audio MIME type (${mimeType || 'absent'})`;
|
||||||
|
}
|
||||||
|
if (!this.FILE_TYPE_RESTRICTIONS.voice.mimeTypes.includes(mimeType)) {
|
||||||
|
return `unsupported audio MIME type (${mimeType})`;
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(size) || size <= 0 || size > this.MAX_AUTO_ACCEPT_VOICE_SIZE) {
|
||||||
|
return `too large to auto-accept (${this.formatFileSize(size || 0)} > ${this.formatFileSize(this.MAX_AUTO_ACCEPT_VOICE_SIZE)})`;
|
||||||
|
}
|
||||||
|
if (this.autoAcceptedVoiceBytes + size > this.MAX_AUTO_ACCEPT_VOICE_SESSION_BYTES) {
|
||||||
|
return 'session auto-accept budget for voice notes is exhausted';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatFileSize(bytes) {
|
formatFileSize(bytes) {
|
||||||
@@ -1331,13 +1381,25 @@ class EnhancedSecureFileTransfer {
|
|||||||
throw new Error('Too many pending incoming file requests');
|
throw new Error('Too many pending incoming file requests');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (validation.voiceRejection) {
|
||||||
|
// Downgraded, not dropped: the peer may well be sending something
|
||||||
|
// legitimate that simply does not qualify for the consent-free path.
|
||||||
|
console.warn(`Voice auto-accept declined, falling back to consent: ${validation.voiceRejection}`);
|
||||||
|
}
|
||||||
|
|
||||||
const pendingMetadata = {
|
const pendingMetadata = {
|
||||||
...metadata,
|
...metadata,
|
||||||
|
// Never carry the sender's claim forward — only our own verdict.
|
||||||
|
isVoice: validation.isVoice,
|
||||||
fileName: validation.displayName,
|
fileName: validation.displayName,
|
||||||
receivedAt: Date.now()
|
receivedAt: Date.now()
|
||||||
};
|
};
|
||||||
this.pendingIncomingTransfers.set(metadata.fileId, pendingMetadata);
|
this.pendingIncomingTransfers.set(metadata.fileId, pendingMetadata);
|
||||||
|
|
||||||
|
if (validation.isVoice) {
|
||||||
|
this.autoAcceptedVoiceBytes += metadata.fileSize;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof this.onIncomingFileRequest === 'function') {
|
if (typeof this.onIncomingFileRequest === 'function') {
|
||||||
this.onIncomingFileRequest({
|
this.onIncomingFileRequest({
|
||||||
fileId: pendingMetadata.fileId,
|
fileId: pendingMetadata.fileId,
|
||||||
@@ -1345,7 +1407,8 @@ class EnhancedSecureFileTransfer {
|
|||||||
fileSize: pendingMetadata.fileSize,
|
fileSize: pendingMetadata.fileSize,
|
||||||
mimeType: pendingMetadata.fileType || 'application/octet-stream',
|
mimeType: pendingMetadata.fileType || 'application/octet-stream',
|
||||||
// Voice notes auto-accept and render inline (no consent card).
|
// Voice notes auto-accept and render inline (no consent card).
|
||||||
isVoice: !!pendingMetadata.isVoice,
|
// This flag is the receiver's decision, not the sender's.
|
||||||
|
isVoice: validation.isVoice,
|
||||||
voice: pendingMetadata.voice || null
|
voice: pendingMetadata.voice || null
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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
|
// 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,
|
// 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.
|
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
|
||||||
const SW_BUILD_VERSION = '1785719685129';
|
const SW_BUILD_VERSION = '1785985047695';
|
||||||
|
|
||||||
// Load version from meta.json on install
|
// Load version from meta.json on install
|
||||||
async function getAppVersion() {
|
async function getAppVersion() {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Regression tests for the SAS-bypass and the unauthenticated control plane.
|
||||||
|
//
|
||||||
|
// Both bugs shared a root cause: "the peer completed the handshake" was treated
|
||||||
|
// as "the peer is who the user thinks it is". It is not — a MITM who sits on the
|
||||||
|
// out-of-band invite channel completes the handshake too, and holds the session
|
||||||
|
// keys. Only the SAS comparison distinguishes them, so nothing that reshapes the
|
||||||
|
// session may happen before it.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
globalThis.window = {
|
||||||
|
EnhancedSecureCryptoUtils: { secureLog: { log() {} } }
|
||||||
|
};
|
||||||
|
globalThis.CustomEvent = class CustomEvent {
|
||||||
|
constructor(type, init) { this.type = type; this.detail = init?.detail; }
|
||||||
|
};
|
||||||
|
globalThis.document = { dispatchEvent() {} };
|
||||||
|
|
||||||
|
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
||||||
|
const P = EnhancedSecureWebRTCManager.prototype;
|
||||||
|
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
|
||||||
|
|
||||||
|
const FP = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';
|
||||||
|
const sdpWith = (fp) => `v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\ns=-\r\na=fingerprint:sha-256 ${fp}\r\n`;
|
||||||
|
|
||||||
|
// ── the fingerprint comparison must not grant verification ───────────────────
|
||||||
|
// sessionMode is 'ratchet' for every session, so this branch ran on every
|
||||||
|
// restart round-trip. Because matching fingerprints are the NORMAL case, it
|
||||||
|
// effectively meant "any peer that echoes back the identity we already know is
|
||||||
|
// verified" — reachable with a single frame, and it bypassed _setVerifiedStatus
|
||||||
|
// and therefore the local-SAS-confirmation check it exists to enforce.
|
||||||
|
{
|
||||||
|
const mgr = {
|
||||||
|
sessionMode: 'ratchet',
|
||||||
|
isVerified: false,
|
||||||
|
_secureLog() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const same = await P._validateDTLSFingerprint.call(mgr, FP, FP, 'ice_restart_offer');
|
||||||
|
assert.equal(same, true, 'identical fingerprints must still compare equal');
|
||||||
|
assert.equal(mgr.isVerified, false,
|
||||||
|
'comparing fingerprints must never mark the session verified');
|
||||||
|
|
||||||
|
// And a genuine mismatch must still be refused, loudly.
|
||||||
|
await assert.rejects(
|
||||||
|
() => P._validateDTLSFingerprint.call(mgr, FP, '00:' + FP.slice(3), 'ice_restart_offer'),
|
||||||
|
/mismatch/i,
|
||||||
|
'a changed DTLS identity must be refused'
|
||||||
|
);
|
||||||
|
assert.equal(mgr.isVerified, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── _setVerifiedStatus remains the only way in ───────────────────────────────
|
||||||
|
{
|
||||||
|
const mgr = {
|
||||||
|
isVerified: false,
|
||||||
|
encryptionKey: {}, macKey: {},
|
||||||
|
localVerificationConfirmed: false,
|
||||||
|
keyFingerprint: 'x',
|
||||||
|
_secureLog() {},
|
||||||
|
onStatusChange() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => P._setVerifiedStatus.call(mgr, true, 'MUTUAL_SAS_CONFIRMED'),
|
||||||
|
/local SAS confirmation/i,
|
||||||
|
'a SAS-based transition without local confirmation must be refused'
|
||||||
|
);
|
||||||
|
assert.equal(mgr.isVerified, false);
|
||||||
|
|
||||||
|
mgr.localVerificationConfirmed = true;
|
||||||
|
P._setVerifiedStatus.call(mgr, true, 'MUTUAL_SAS_CONFIRMED');
|
||||||
|
assert.equal(mgr.isVerified, true, 'the legitimate path must still work');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── control frames are refused before verification, honoured after ───────────
|
||||||
|
{
|
||||||
|
const makeChannelManager = (isVerified) => {
|
||||||
|
const seen = { deleted: [], delivered: [], call: [], ice: [] };
|
||||||
|
const mgr = {
|
||||||
|
isVerified,
|
||||||
|
_secureLog() {},
|
||||||
|
_noteInboundActivity() {},
|
||||||
|
_enforceVerificationGate: P._enforceVerificationGate,
|
||||||
|
establishConnection: async () => {},
|
||||||
|
initializeFileTransfer() {},
|
||||||
|
_notifyVerificationReadyIfPossible() {},
|
||||||
|
initiateVerification() {},
|
||||||
|
processMessageQueue() {},
|
||||||
|
onStatusChange() {},
|
||||||
|
_resetReconnectState() {},
|
||||||
|
_teardownRecoveryLifecycleListeners() {},
|
||||||
|
startHeartbeat() {},
|
||||||
|
// The verified branch of the open handler schedules these on a timer.
|
||||||
|
calculateAndReportSecurityLevel: async () => {},
|
||||||
|
autoEnableSecurityFeatures() {},
|
||||||
|
notifySecurityUpdate() {},
|
||||||
|
pendingSASCode: null,
|
||||||
|
onMessageDelete: (id) => seen.deleted.push(id),
|
||||||
|
onMessageDelivered: (id) => seen.delivered.push(id),
|
||||||
|
_handleCallSignal: async (type) => { seen.call.push(type); },
|
||||||
|
_handleIceRestartSignal: async (type) => { seen.ice.push(type); },
|
||||||
|
setupDataChannel: P.setupDataChannel
|
||||||
|
};
|
||||||
|
const channel = { readyState: 'open', send() {} };
|
||||||
|
mgr.setupDataChannel(channel);
|
||||||
|
return { mgr, channel, seen };
|
||||||
|
};
|
||||||
|
|
||||||
|
const frames = [
|
||||||
|
[T.ICE_RESTART_OFFER, { sdp: sdpWith(FP) }],
|
||||||
|
[T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP) }],
|
||||||
|
[T.ICE_RESTART_REQUEST, {}],
|
||||||
|
[T.CALL_OFFER, { sdp: sdpWith(FP), callId: 'c1' }],
|
||||||
|
[T.CALL_ANSWER, { sdp: sdpWith(FP) }],
|
||||||
|
[T.CALL_ICE, { candidate: {} }],
|
||||||
|
[T.CALL_DECLINE, {}],
|
||||||
|
[T.CALL_END, {}],
|
||||||
|
[T.MESSAGE_DELETE, { messageId: 'm1' }],
|
||||||
|
[T.MESSAGE_RECEIPT, { messageId: 'm1' }]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Unverified: this is the MITM window. Nothing may take effect.
|
||||||
|
{
|
||||||
|
const { channel, seen } = makeChannelManager(false);
|
||||||
|
for (const [type, data] of frames) {
|
||||||
|
await channel.onmessage({ data: JSON.stringify({ type, data }) });
|
||||||
|
}
|
||||||
|
assert.deepEqual(seen.ice, [], 'no ICE restart may be driven before verification');
|
||||||
|
assert.deepEqual(seen.call, [], 'no call may be signalled before verification');
|
||||||
|
assert.deepEqual(seen.deleted, [], 'no message may be deleted before verification');
|
||||||
|
assert.deepEqual(seen.delivered, [], 'no receipt may be forged before verification');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verified: the features must still work — a gate that breaks the product
|
||||||
|
// gets removed by the next person who touches this file.
|
||||||
|
{
|
||||||
|
const { channel, seen } = makeChannelManager(true);
|
||||||
|
for (const [type, data] of frames) {
|
||||||
|
await channel.onmessage({ data: JSON.stringify({ type, data }) });
|
||||||
|
}
|
||||||
|
assert.deepEqual(seen.ice,
|
||||||
|
[T.ICE_RESTART_OFFER, T.ICE_RESTART_ANSWER, T.ICE_RESTART_REQUEST]);
|
||||||
|
assert.deepEqual(seen.call,
|
||||||
|
[T.CALL_OFFER, T.CALL_ANSWER, T.CALL_ICE, T.CALL_DECLINE, T.CALL_END]);
|
||||||
|
assert.deepEqual(seen.deleted, ['m1']);
|
||||||
|
assert.deepEqual(seen.delivered, ['m1']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every gated type must be in the allowlist, or it silently falls through to
|
||||||
|
// the chat channel's default-deny branch and the feature breaks instead.
|
||||||
|
for (const [type] of frames) {
|
||||||
|
assert.ok(EnhancedSecureWebRTCManager.POST_VERIFICATION_CONTROL_TYPES.has(type),
|
||||||
|
`${type} must be declared as a post-verification control frame`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verification handshake itself must NOT be gated: it has to run before
|
||||||
|
// verification exists, otherwise no session could ever be established.
|
||||||
|
for (const type of [T.HEARTBEAT, T.VERIFICATION, T.VERIFICATION_RESPONSE,
|
||||||
|
T.VERIFICATION_CONFIRMED, T.VERIFICATION_BOTH_CONFIRMED]) {
|
||||||
|
assert.equal(EnhancedSecureWebRTCManager.POST_VERIFICATION_CONTROL_TYPES.has(type), false,
|
||||||
|
`${type} must stay reachable before verification`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the legacy processMessage pipeline delivers nothing unauthenticated ──────
|
||||||
|
// It is unreachable from the live handler today, but it is a second inbound
|
||||||
|
// pipeline with weaker rules; if it ever gets called it must not undo the fix.
|
||||||
|
{
|
||||||
|
const delivered = [];
|
||||||
|
const mgr = {
|
||||||
|
isVerified: false,
|
||||||
|
_secureLog() {},
|
||||||
|
_noteInboundActivity() {},
|
||||||
|
_checkInboundRateLimit: () => true,
|
||||||
|
_enforceVerificationGate: P._enforceVerificationGate,
|
||||||
|
onMessage: () => {},
|
||||||
|
deliverMessageToUI: (m) => delivered.push(m),
|
||||||
|
onMessageDelete: (id) => delivered.push(`delete:${id}`),
|
||||||
|
onMessageDelivered: (id) => delivered.push(`receipt:${id}`),
|
||||||
|
_handleCallSignal: async () => { delivered.push('call'); },
|
||||||
|
_handleIceRestartSignal: async () => { delivered.push('ice'); },
|
||||||
|
processMessage: P.processMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
await mgr.processMessage(JSON.stringify({ type: 'message', data: 'injected' }));
|
||||||
|
await mgr.processMessage('not json at all');
|
||||||
|
await mgr.processMessage(JSON.stringify({ type: T.ICE_RESTART_OFFER, data: { sdp: sdpWith(FP) } }));
|
||||||
|
await mgr.processMessage(JSON.stringify({ type: T.MESSAGE_DELETE, data: { messageId: 'm1' } }));
|
||||||
|
|
||||||
|
assert.deepEqual(delivered, [],
|
||||||
|
'the legacy pipeline must not deliver or act on unauthenticated frames');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('control-frame-authorization.test.mjs: all assertions passed');
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// Double Ratchet correctness and its security properties.
|
||||||
|
//
|
||||||
|
// The point of the ratchet is that a key recovered at time T must not open
|
||||||
|
// anything sent before T, and that one exchange in each direction must lock out
|
||||||
|
// an attacker who captured the whole state. Both are asserted here directly,
|
||||||
|
// not inferred from the code shape.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
globalThis.window = { document: {} };
|
||||||
|
|
||||||
|
const { DoubleRatchet, RATCHET_LIMITS } = await import('../src/crypto/DoubleRatchet.js');
|
||||||
|
|
||||||
|
const subtle = crypto.subtle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The peer's key must arrive the way production delivers it: through
|
||||||
|
* importSignedPublicKey, which imports SPKI as NON-EXTRACTABLE. A generated
|
||||||
|
* public key is always extractable regardless of the flag, so a test that passes
|
||||||
|
* `keyPair.publicKey` straight through exercises a key shape that never occurs
|
||||||
|
* in the app — and misses anything that tries to export it. That is exactly how
|
||||||
|
* a ratchet-setup failure on the initiator reached production.
|
||||||
|
*/
|
||||||
|
async function asReceivedFromPeer(publicKey) {
|
||||||
|
const spki = await subtle.exportKey('spki', publicKey);
|
||||||
|
const imported = await subtle.importKey('spki', spki, { name: 'ECDH', namedCurve: 'P-384' }, false, []);
|
||||||
|
assert.equal(imported.extractable, false, 'the stand-in must be non-extractable, like the real one');
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePair() {
|
||||||
|
const alice = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, false, ['deriveKey', 'deriveBits']);
|
||||||
|
const bob = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, false, ['deriveKey', 'deriveBits']);
|
||||||
|
|
||||||
|
const shared = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: bob.publicKey }, alice.privateKey, 256));
|
||||||
|
const sessionSalt = crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
|
||||||
|
const a = new DoubleRatchet();
|
||||||
|
const b = new DoubleRatchet();
|
||||||
|
await a.init({
|
||||||
|
sharedSecret: shared.slice(), sessionSalt, selfPrivateKey: alice.privateKey,
|
||||||
|
remotePublicKey: await asReceivedFromPeer(bob.publicKey), isInitiator: true
|
||||||
|
});
|
||||||
|
await b.init({
|
||||||
|
sharedSecret: shared.slice(), sessionSalt, selfPrivateKey: bob.privateKey,
|
||||||
|
remotePublicKey: await asReceivedFromPeer(alice.publicKey), isInitiator: false
|
||||||
|
});
|
||||||
|
return { a, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async (from, to, text) => {
|
||||||
|
const { header, ciphertext } = await from.encrypt(text);
|
||||||
|
return { header, ciphertext, open: () => to.decrypt(header, ciphertext) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── the basic round trip, in both directions ─────────────────────────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
|
||||||
|
// The responder cannot speak first: it has no sending chain until the
|
||||||
|
// initiator's first message arrives. This is by design, not a bug.
|
||||||
|
await assert.rejects(() => b.encrypt('too early'), /no sending chain/);
|
||||||
|
|
||||||
|
const m1 = await send(a, b, 'hello bob');
|
||||||
|
assert.equal(await m1.open(), 'hello bob');
|
||||||
|
|
||||||
|
// Now Bob can reply, and doing so introduces his own ratchet key.
|
||||||
|
const m2 = await send(b, a, 'hello alice');
|
||||||
|
assert.equal(await m2.open(), 'hello alice');
|
||||||
|
|
||||||
|
const m3 = await send(a, b, 'how are you');
|
||||||
|
assert.equal(await m3.open(), 'how are you');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── every message uses a different key ───────────────────────────────────────
|
||||||
|
// Identical plaintexts must not produce identical ciphertexts; if they did, the
|
||||||
|
// chain would not be advancing at all.
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const { header, ciphertext } = await a.encrypt('same text every time');
|
||||||
|
assert.equal(seen.has(ciphertext), false, `ciphertext repeated at message ${i}`);
|
||||||
|
seen.add(ciphertext);
|
||||||
|
assert.equal(await b.decrypt(header, ciphertext), 'same text every time');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FORWARD SECRECY: the current state cannot open earlier messages ──────────
|
||||||
|
// This is the property the audit found missing. Capture a ciphertext, let the
|
||||||
|
// conversation move on, then hand the receiver's live state the old frame: it
|
||||||
|
// must fail, because the key that opened it was destroyed on use.
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
|
||||||
|
const early = await a.encrypt('the secret from the start of the session');
|
||||||
|
assert.equal(await b.decrypt(early.header, early.ciphertext), 'the secret from the start of the session');
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const m = await a.encrypt(`later message ${i}`);
|
||||||
|
await b.decrypt(m.header, m.ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => b.decrypt(early.header, early.ciphertext),
|
||||||
|
/behind the current chain|authentication failed/,
|
||||||
|
'a compromised current state must not reopen an earlier message'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── replay is refused ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
const m = await a.encrypt('deliver once');
|
||||||
|
assert.equal(await b.decrypt(m.header, m.ciphertext), 'deliver once');
|
||||||
|
await assert.rejects(() => b.decrypt(m.header, m.ciphertext), /behind the current chain/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST-COMPROMISE SECURITY: the DH ratchet re-keys the root ────────────────
|
||||||
|
// After a full exchange in each direction the sending chain must derive from a
|
||||||
|
// DH secret the attacker never saw. Observable proxy: the ratchet public key in
|
||||||
|
// the header changes when the direction turns.
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
|
||||||
|
const first = await a.encrypt('one');
|
||||||
|
await b.decrypt(first.header, first.ciphertext);
|
||||||
|
const aliceKey1 = JSON.parse(first.header).dh;
|
||||||
|
|
||||||
|
const reply = await b.encrypt('two');
|
||||||
|
await a.decrypt(reply.header, reply.ciphertext);
|
||||||
|
const bobKey1 = JSON.parse(reply.header).dh;
|
||||||
|
assert.notEqual(bobKey1, aliceKey1, 'each side contributes its own ratchet key');
|
||||||
|
|
||||||
|
const third = await a.encrypt('three');
|
||||||
|
await b.decrypt(third.header, third.ciphertext);
|
||||||
|
const aliceKey2 = JSON.parse(third.header).dh;
|
||||||
|
assert.notEqual(aliceKey2, aliceKey1,
|
||||||
|
'replying must adopt a fresh ratchet key — this is what recovers from compromise');
|
||||||
|
|
||||||
|
// Message numbering restarts per chain, and the previous length is carried.
|
||||||
|
assert.equal(JSON.parse(third.header).n, 0);
|
||||||
|
assert.equal(JSON.parse(third.header).pn, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── out-of-order delivery inside a chain ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
const frames = [];
|
||||||
|
for (let i = 0; i < 5; i++) frames.push(await a.encrypt(`m${i}`));
|
||||||
|
|
||||||
|
// Arrive 4, 0, 2, 1, 3.
|
||||||
|
assert.equal(await b.decrypt(frames[4].header, frames[4].ciphertext), 'm4');
|
||||||
|
assert.equal(await b.decrypt(frames[0].header, frames[0].ciphertext), 'm0');
|
||||||
|
assert.equal(await b.decrypt(frames[2].header, frames[2].ciphertext), 'm2');
|
||||||
|
assert.equal(await b.decrypt(frames[1].header, frames[1].ciphertext), 'm1');
|
||||||
|
assert.equal(await b.decrypt(frames[3].header, frames[3].ciphertext), 'm3');
|
||||||
|
assert.equal(b.getState().skippedKeys, 0, 'every retained key must be consumed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── out-of-order ACROSS a ratchet step ───────────────────────────────────────
|
||||||
|
// A message from the previous chain arriving after the direction changed is the
|
||||||
|
// case that breaks naive implementations.
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
|
||||||
|
const straggler = await a.encrypt('sent before the turn');
|
||||||
|
const delivered = await a.encrypt('delivered first');
|
||||||
|
await b.decrypt(delivered.header, delivered.ciphertext);
|
||||||
|
|
||||||
|
const reply = await b.encrypt('bob replies');
|
||||||
|
await a.decrypt(reply.header, reply.ciphertext);
|
||||||
|
const after = await a.encrypt('new chain');
|
||||||
|
await b.decrypt(after.header, after.ciphertext);
|
||||||
|
|
||||||
|
assert.equal(await b.decrypt(straggler.header, straggler.ciphertext), 'sent before the turn',
|
||||||
|
'a message from the previous chain must still open after a ratchet step');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DoS: an attacker cannot make us retain unbounded keys ────────────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
const m = await a.encrypt('probe');
|
||||||
|
const header = JSON.parse(m.header);
|
||||||
|
|
||||||
|
// A single frame claiming a huge message number would otherwise force us to
|
||||||
|
// derive and hold that many keys.
|
||||||
|
const absurd = JSON.stringify({ ...header, n: 5_000_000 });
|
||||||
|
await assert.rejects(
|
||||||
|
() => b.decrypt(absurd, m.ciphertext),
|
||||||
|
/refusing to skip/,
|
||||||
|
'a large forward jump must be refused, not honoured'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Just past the limit is still refused; the limit itself is workable.
|
||||||
|
const overLimit = JSON.stringify({ ...header, n: RATCHET_LIMITS.MAX_SKIP_PER_CHAIN + 1 });
|
||||||
|
await assert.rejects(() => b.decrypt(overLimit, m.ciphertext), /refusing to skip/);
|
||||||
|
|
||||||
|
assert.equal(b.getState().skippedKeys, 0, 'a refused frame must leave no keys behind');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the retained-key cache is bounded ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
const frames = [];
|
||||||
|
const gap = 200;
|
||||||
|
for (let round = 0; round < 8; round++) {
|
||||||
|
for (let i = 0; i < gap; i++) frames.push(await a.encrypt(`x${round}-${i}`));
|
||||||
|
const marker = await a.encrypt(`marker-${round}`);
|
||||||
|
await b.decrypt(marker.header, marker.ciphertext);
|
||||||
|
}
|
||||||
|
assert.ok(b.getState().skippedKeys <= RATCHET_LIMITS.MAX_SKIPPED_KEYS,
|
||||||
|
`retained keys (${b.getState().skippedKeys}) must stay within the cap`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a tampered header is rejected AND leaves the ratchet intact ──────────────
|
||||||
|
// The header is plaintext on the wire, so this is reachable. The session must
|
||||||
|
// survive it: a bad frame that desynchronised the chains would be a remote
|
||||||
|
// denial of service against an established chat.
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
const m = await a.encrypt('authentic');
|
||||||
|
const forged = JSON.stringify({ ...JSON.parse(m.header), pn: 99 });
|
||||||
|
|
||||||
|
await assert.rejects(() => b.decrypt(forged, m.ciphertext), /authentication failed/);
|
||||||
|
|
||||||
|
// The genuine frame must still open afterwards.
|
||||||
|
assert.equal(await b.decrypt(m.header, m.ciphertext), 'authentic');
|
||||||
|
|
||||||
|
// And the conversation continues normally.
|
||||||
|
const next = await a.encrypt('still working');
|
||||||
|
assert.equal(await b.decrypt(next.header, next.ciphertext), 'still working');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a tampered body is rejected, likewise without side effects ───────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
const m = await a.encrypt('authentic body');
|
||||||
|
const flipped = Buffer.from(m.ciphertext, 'base64');
|
||||||
|
flipped[flipped.length - 1] ^= 0xff;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => b.decrypt(m.header, flipped.toString('base64')),
|
||||||
|
/authentication failed/
|
||||||
|
);
|
||||||
|
assert.equal(await b.decrypt(m.header, m.ciphertext), 'authentic body',
|
||||||
|
'the genuine frame must still open after a forged one');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── two independent sessions never share ratchet state ───────────────────────
|
||||||
|
{
|
||||||
|
const one = await makePair();
|
||||||
|
const two = await makePair();
|
||||||
|
const m = await one.a.encrypt('for session one');
|
||||||
|
await assert.rejects(
|
||||||
|
() => two.b.decrypt(m.header, m.ciphertext),
|
||||||
|
/authentication failed|behind the current chain|no receiving chain/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── destroy() clears the state ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
const m = await a.encrypt('before destroy');
|
||||||
|
await b.decrypt(m.header, m.ciphertext);
|
||||||
|
|
||||||
|
b.destroy();
|
||||||
|
assert.equal(b.getState().initialised, false);
|
||||||
|
assert.equal(b.getState().skippedKeys, 0);
|
||||||
|
const after = await a.encrypt('after destroy');
|
||||||
|
await assert.rejects(() => b.decrypt(after.header, after.ciphertext), /not initialised/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a long conversation stays in sync ────────────────────────────────────────
|
||||||
|
// Ratchet bugs love to appear at chain boundaries rather than on message two.
|
||||||
|
{
|
||||||
|
const { a, b } = await makePair();
|
||||||
|
let expected = 0;
|
||||||
|
for (let turn = 0; turn < 30; turn++) {
|
||||||
|
const from = turn % 2 === 0 ? a : b;
|
||||||
|
const to = turn % 2 === 0 ? b : a;
|
||||||
|
const burst = 1 + (turn % 4);
|
||||||
|
for (let i = 0; i < burst; i++) {
|
||||||
|
const text = `turn ${turn} message ${i}`;
|
||||||
|
const { header, ciphertext } = await from.encrypt(text);
|
||||||
|
assert.equal(await to.decrypt(header, ciphertext), text);
|
||||||
|
expected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(expected > 60, 'the exchange should have covered many chain switches');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('double-ratchet.test.mjs: all assertions passed');
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// ICE gathering only reaches 'complete' once every configured STUN/TURN server
|
||||||
|
// has answered or timed out. On a network that blocks them — a VPN, a captive
|
||||||
|
// portal, an interface the browser cannot route from — that never happens, even
|
||||||
|
// though host candidates are available immediately and are enough to connect on
|
||||||
|
// a LAN.
|
||||||
|
//
|
||||||
|
// The old code waited a flat 10 s and then failed the whole handshake if the SDP
|
||||||
|
// happened to be empty at that instant. That made success a coin flip: the same
|
||||||
|
// device failed one attempt and connected on the next with gathering still in
|
||||||
|
// progress (observed in the field, 11 candidates at 10001 ms).
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } };
|
||||||
|
globalThis.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i?.detail; } };
|
||||||
|
globalThis.document = { dispatchEvent() {} };
|
||||||
|
|
||||||
|
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
||||||
|
const P = EnhancedSecureWebRTCManager.prototype;
|
||||||
|
const T = EnhancedSecureWebRTCManager.TIMEOUTS;
|
||||||
|
|
||||||
|
// Fake timers so the 10 s / 25 s deadlines can be driven by hand.
|
||||||
|
const realSetTimeout = globalThis.setTimeout;
|
||||||
|
const realClearTimeout = globalThis.clearTimeout;
|
||||||
|
let timers = [];
|
||||||
|
globalThis.setTimeout = (fn, delay) => {
|
||||||
|
const t = { fn, delay, cleared: false };
|
||||||
|
timers.push(t);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
globalThis.clearTimeout = (t) => { if (t) t.cleared = true; };
|
||||||
|
const fireDelay = (delay) => {
|
||||||
|
for (const t of timers.filter((x) => !x.cleared && x.delay === delay)) {
|
||||||
|
t.cleared = true;
|
||||||
|
t.fn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sdpWithCandidates = (n) =>
|
||||||
|
'v=0\r\n' + Array.from({ length: n }, (_, i) =>
|
||||||
|
`a=candidate:${i} 1 udp 2122260223 192.168.1.${i + 2} 5000${i} typ host\r\n`).join('');
|
||||||
|
|
||||||
|
function makeManager(candidateCount) {
|
||||||
|
const listeners = [];
|
||||||
|
const ui = [];
|
||||||
|
return {
|
||||||
|
ui,
|
||||||
|
listeners,
|
||||||
|
peerConnection: {
|
||||||
|
iceGatheringState: 'gathering',
|
||||||
|
localDescription: { sdp: sdpWithCandidates(candidateCount) },
|
||||||
|
addEventListener: (name, fn) => listeners.push({ name, fn }),
|
||||||
|
removeEventListener: () => {}
|
||||||
|
},
|
||||||
|
_activeTimers: new Set(),
|
||||||
|
_secureLog() {},
|
||||||
|
deliverMessageToUI: (m) => ui.push(m),
|
||||||
|
_trackActiveTimer: P._trackActiveTimer,
|
||||||
|
_untrackActiveTimer: P._untrackActiveTimer,
|
||||||
|
_summarizeIceCandidatesInSDP: P._summarizeIceCandidatesInSDP,
|
||||||
|
_countIceCandidatesInSDP: P._countIceCandidatesInSDP,
|
||||||
|
waitForIceGathering: P.waitForIceGathering
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── the budget must actually be longer than the soft deadline ────────────
|
||||||
|
assert.ok(T.ICE_GATHERING_HARD_TIMEOUT > T.ICE_GATHERING_TIMEOUT,
|
||||||
|
'the hard ceiling must leave room past the soft deadline');
|
||||||
|
|
||||||
|
// ── already complete: return immediately, no waiting ─────────────────────
|
||||||
|
{
|
||||||
|
timers = [];
|
||||||
|
const mgr = makeManager(3);
|
||||||
|
mgr.peerConnection.iceGatheringState = 'complete';
|
||||||
|
assert.equal(await mgr.waitForIceGathering(), true);
|
||||||
|
assert.deepEqual(timers.filter((t) => !t.cleared), [], 'no timers left behind');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── gathering finishes on its own: resolve true and drop the timers ──────
|
||||||
|
{
|
||||||
|
timers = [];
|
||||||
|
const mgr = makeManager(3);
|
||||||
|
const pending = mgr.waitForIceGathering();
|
||||||
|
mgr.peerConnection.iceGatheringState = 'complete';
|
||||||
|
mgr.listeners.forEach((l) => l.fn());
|
||||||
|
assert.equal(await pending, true);
|
||||||
|
assert.deepEqual(timers.filter((t) => !t.cleared), [],
|
||||||
|
'a completed gather must not leave the hard timer armed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── soft deadline WITH candidates: stop waiting, report "not complete" ───
|
||||||
|
// This is the common case on a restricted network, and it must succeed: the
|
||||||
|
// caller only refuses to export when there is nothing at all.
|
||||||
|
{
|
||||||
|
timers = [];
|
||||||
|
const mgr = makeManager(11);
|
||||||
|
const pending = mgr.waitForIceGathering();
|
||||||
|
fireDelay(T.ICE_GATHERING_TIMEOUT);
|
||||||
|
assert.equal(await pending, false, 'gathering did not complete...');
|
||||||
|
// ...but the caller's guard is `!completed && count === 0`, so 11
|
||||||
|
// candidates mean the handshake proceeds.
|
||||||
|
assert.ok(mgr._summarizeIceCandidatesInSDP(mgr.peerConnection.localDescription.sdp).total > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── soft deadline with NOTHING: keep waiting instead of failing ──────────
|
||||||
|
// The regression under test. Previously this resolved at 10 s with an empty
|
||||||
|
// SDP and the handshake threw.
|
||||||
|
{
|
||||||
|
timers = [];
|
||||||
|
const mgr = makeManager(0);
|
||||||
|
let settled = false;
|
||||||
|
const pending = mgr.waitForIceGathering().then((v) => { settled = true; return v; });
|
||||||
|
|
||||||
|
fireDelay(T.ICE_GATHERING_TIMEOUT);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(settled, false, 'an empty SDP at the soft deadline must not end the wait');
|
||||||
|
|
||||||
|
// A candidate arriving late is exactly what the extra patience buys.
|
||||||
|
mgr.peerConnection.localDescription.sdp = sdpWithCandidates(4);
|
||||||
|
mgr.peerConnection.iceGatheringState = 'complete';
|
||||||
|
mgr.listeners.forEach((l) => l.fn());
|
||||||
|
assert.equal(await pending, true, 'a late completion must still be picked up');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a genuinely dead network still fails, at the hard ceiling ────────────
|
||||||
|
{
|
||||||
|
timers = [];
|
||||||
|
const mgr = makeManager(0);
|
||||||
|
const pending = mgr.waitForIceGathering();
|
||||||
|
fireDelay(T.ICE_GATHERING_TIMEOUT);
|
||||||
|
fireDelay(T.ICE_GATHERING_HARD_TIMEOUT);
|
||||||
|
assert.equal(await pending, false, 'nothing gathered at all must eventually give up');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a caller-supplied budget shorter than the hard default is honoured ───
|
||||||
|
// Session recovery passes 4 s and must not silently wait 25 s instead.
|
||||||
|
{
|
||||||
|
timers = [];
|
||||||
|
const mgr = makeManager(0);
|
||||||
|
const pending = mgr.waitForIceGathering(T.ICE_RESTART_GATHERING, T.ICE_RESTART_GATHERING);
|
||||||
|
fireDelay(T.ICE_RESTART_GATHERING);
|
||||||
|
assert.equal(await pending, false, 'the recovery path keeps its short budget');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ice-gathering-patience.test.mjs: all assertions passed');
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = realSetTimeout;
|
||||||
|
globalThis.clearTimeout = realClearTimeout;
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
// Each call returns the next queued plaintext, so a flood can be distinguished
|
||||||
|
// message by message rather than all looking alike.
|
||||||
|
let nextPlaintext = 'hello';
|
||||||
globalThis.window = {
|
globalThis.window = {
|
||||||
EnhancedSecureCryptoUtils: {
|
EnhancedSecureCryptoUtils: {
|
||||||
async decryptMessage() {
|
async decryptMessage() {
|
||||||
return { message: JSON.stringify({ type: 'message', data: 'enhanced hello' }) };
|
return {
|
||||||
|
message: JSON.stringify({ type: 'message', data: nextPlaintext }),
|
||||||
|
messageId: 'msg_1',
|
||||||
|
sequenceNumber: 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -18,6 +25,12 @@ function fakeManager({ perMinute = 60, burst = 10 } = {}) {
|
|||||||
rateLimitMessagesPerMinute: perMinute,
|
rateLimitMessagesPerMinute: perMinute,
|
||||||
rateLimitBurstSize: burst
|
rateLimitBurstSize: burst
|
||||||
},
|
},
|
||||||
|
encryptionKey: {},
|
||||||
|
macKey: {},
|
||||||
|
metadataKey: {},
|
||||||
|
// Anti-replay is a separate mechanism with its own test; keep this one
|
||||||
|
// focused on rate limiting.
|
||||||
|
_validateIncomingSequenceNumber: () => true,
|
||||||
_checkInboundRateLimit: EnhancedSecureWebRTCManager.prototype._checkInboundRateLimit,
|
_checkInboundRateLimit: EnhancedSecureWebRTCManager.prototype._checkInboundRateLimit,
|
||||||
_secureLog(level, message, context) {
|
_secureLog(level, message, context) {
|
||||||
this.logs.push({ level, message, context });
|
this.logs.push({ level, message, context });
|
||||||
@@ -29,21 +42,30 @@ function fakeManager({ perMinute = 60, burst = 10 } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rate limiter is exercised through `enhanced_message`, the only frame type
|
||||||
|
// that carries chat content. It used to be driven here through a bare
|
||||||
|
// `{type:'message'}` frame, but those are rejected now: they were
|
||||||
|
// unauthenticated peer input rendered as a real message.
|
||||||
|
const deliver = (manager, text) => {
|
||||||
|
nextPlaintext = text;
|
||||||
|
return EnhancedSecureWebRTCManager.prototype._processEnhancedMessageWithoutMutex.call(
|
||||||
|
manager,
|
||||||
|
{ type: 'enhanced_message', data: 'ciphertext' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Normal inbound messages are delivered.
|
// Normal inbound messages are delivered.
|
||||||
{
|
{
|
||||||
const manager = fakeManager();
|
const manager = fakeManager();
|
||||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(
|
await deliver(manager, 'hello');
|
||||||
manager,
|
|
||||||
JSON.stringify({ type: 'message', data: 'hello' })
|
|
||||||
);
|
|
||||||
assert.deepEqual(manager.delivered, [{ message: 'hello', type: 'received' }]);
|
assert.deepEqual(manager.delivered, [{ message: 'hello', type: 'received' }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Burst floods are dropped safely and logged.
|
// Burst floods are dropped safely and logged.
|
||||||
{
|
{
|
||||||
const manager = fakeManager({ burst: 1 });
|
const manager = fakeManager({ burst: 1 });
|
||||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'first' }));
|
await deliver(manager, 'first');
|
||||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'second' }));
|
await deliver(manager, 'second');
|
||||||
assert.deepEqual(manager.delivered, [{ message: 'first', type: 'received' }]);
|
assert.deepEqual(manager.delivered, [{ message: 'first', type: 'received' }]);
|
||||||
assert.match(manager.logs.at(-1).message, /Inbound message burst limit exceeded/);
|
assert.match(manager.logs.at(-1).message, /Inbound message burst limit exceeded/);
|
||||||
}
|
}
|
||||||
@@ -51,13 +73,24 @@ function fakeManager({ perMinute = 60, burst = 10 } = {}) {
|
|||||||
// Sustained-window floods are rejected independently of burst accounting.
|
// Sustained-window floods are rejected independently of burst accounting.
|
||||||
{
|
{
|
||||||
const manager = fakeManager({ perMinute: 1, burst: 10 });
|
const manager = fakeManager({ perMinute: 1, burst: 10 });
|
||||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'first' }));
|
await deliver(manager, 'first');
|
||||||
manager._inboundRateLimiter.lastBurstReset = Date.now() - 1001;
|
manager._inboundRateLimiter.lastBurstReset = Date.now() - 1001;
|
||||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'second' }));
|
await deliver(manager, 'second');
|
||||||
assert.deepEqual(manager.delivered, [{ message: 'first', type: 'received' }]);
|
assert.deepEqual(manager.delivered, [{ message: 'first', type: 'received' }]);
|
||||||
assert.match(manager.logs.at(-1).message, /Inbound message rate limit exceeded/);
|
assert.match(manager.logs.at(-1).message, /Inbound message rate limit exceeded/);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// And an unauthenticated frame is refused outright, limiter or no limiter —
|
||||||
|
// rate limiting is not what keeps injected chat text out.
|
||||||
|
{
|
||||||
|
const manager = fakeManager();
|
||||||
|
await EnhancedSecureWebRTCManager.prototype.processMessage.call(
|
||||||
|
manager,
|
||||||
|
JSON.stringify({ type: 'message', data: 'injected' })
|
||||||
|
);
|
||||||
|
assert.deepEqual(manager.delivered, [], 'a bare message frame must never be delivered');
|
||||||
|
}
|
||||||
|
|
||||||
// Binary and enhanced helpers are guarded before expensive processing.
|
// Binary and enhanced helpers are guarded before expensive processing.
|
||||||
{
|
{
|
||||||
const binaryManager = {
|
const binaryManager = {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// deriveSharedKeys() stopped routing the ECDH shared secret through an
|
||||||
|
// extractable AES key + exportKey() (which left the secret sitting in the heap
|
||||||
|
// unwiped) and now uses deriveBits() directly.
|
||||||
|
//
|
||||||
|
// That is only safe if the BYTES are identical: both peers must derive the same
|
||||||
|
// session keys, and a 5.6.1 client has to interoperate with a 5.6.0 one. This is
|
||||||
|
// the test that says so — WebCrypto's ECDH deriveBits(n) returns the leftmost n
|
||||||
|
// bits of the shared X coordinate, which is exactly what deriveKey to
|
||||||
|
// AES-GCM-256 consumed. If a future change bumps 256 to 384 "for strength", this
|
||||||
|
// fails, and it should: that is a protocol break, not an improvement.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { webcrypto } from 'node:crypto';
|
||||||
|
|
||||||
|
const { subtle } = webcrypto;
|
||||||
|
|
||||||
|
const toHex = (buf) => Array.from(new Uint8Array(buf))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
for (const namedCurve of ['P-384', 'P-256']) {
|
||||||
|
const alice = await subtle.generateKey({ name: 'ECDH', namedCurve }, false, ['deriveKey', 'deriveBits']);
|
||||||
|
const bob = await subtle.generateKey({ name: 'ECDH', namedCurve }, false, ['deriveKey', 'deriveBits']);
|
||||||
|
|
||||||
|
// ── the old path: extractable AES key, then exportKey ────────────────────
|
||||||
|
const legacyKey = await subtle.deriveKey(
|
||||||
|
{ name: 'ECDH', public: bob.publicKey },
|
||||||
|
alice.privateKey,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
const legacyBytes = await subtle.exportKey('raw', legacyKey);
|
||||||
|
|
||||||
|
// ── the new path: deriveBits, no extractable key, buffer wipeable ────────
|
||||||
|
const newBytes = await subtle.deriveBits(
|
||||||
|
{ name: 'ECDH', public: bob.publicKey },
|
||||||
|
alice.privateKey,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(toHex(newBytes), toHex(legacyBytes),
|
||||||
|
`${namedCurve}: deriveBits(256) must reproduce the previous shared secret exactly`);
|
||||||
|
|
||||||
|
// Sanity: the two peers still agree, which is the property the whole session
|
||||||
|
// rests on and is worth asserting rather than assuming.
|
||||||
|
const bobBytes = await subtle.deriveBits(
|
||||||
|
{ name: 'ECDH', public: alice.publicKey },
|
||||||
|
bob.privateKey,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
assert.equal(toHex(bobBytes), toHex(newBytes), `${namedCurve}: both peers must derive the same secret`);
|
||||||
|
|
||||||
|
// ── the fingerprint material took the same detour ────────────────────────
|
||||||
|
const ikm = await subtle.importKey('raw', newBytes, { name: 'HKDF', hash: 'SHA-256' }, false,
|
||||||
|
['deriveKey', 'deriveBits']);
|
||||||
|
const salt = new Uint8Array(64).fill(7);
|
||||||
|
const info = new TextEncoder().encode('fingerprint-generation-v4');
|
||||||
|
|
||||||
|
const legacyFpKey = await subtle.deriveKey(
|
||||||
|
{ name: 'HKDF', hash: 'SHA-256', salt, info },
|
||||||
|
ikm,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
const legacyFpBytes = await subtle.exportKey('raw', legacyFpKey);
|
||||||
|
const newFpBytes = await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, ikm, 256);
|
||||||
|
|
||||||
|
assert.equal(toHex(newFpBytes), toHex(legacyFpBytes),
|
||||||
|
`${namedCurve}: HKDF deriveBits(256) must reproduce the previous fingerprint material`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── zeroizeBuffer actually overwrites ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
globalThis.window = { document: {} };
|
||||||
|
const { EnhancedSecureCryptoUtils } = await import('../src/crypto/EnhancedSecureCryptoUtils.js');
|
||||||
|
|
||||||
|
const secret = new Uint8Array(32).fill(0xAB);
|
||||||
|
EnhancedSecureCryptoUtils.zeroizeBuffer(secret);
|
||||||
|
assert.ok(secret.every((b) => b === 0), 'a Uint8Array must end up zeroed');
|
||||||
|
|
||||||
|
const buf = new ArrayBuffer(32);
|
||||||
|
new Uint8Array(buf).fill(0xCD);
|
||||||
|
EnhancedSecureCryptoUtils.zeroizeBuffer(buf);
|
||||||
|
assert.ok(new Uint8Array(buf).every((b) => b === 0), 'an ArrayBuffer must end up zeroed');
|
||||||
|
|
||||||
|
// Must not throw on the shapes it will legitimately be handed.
|
||||||
|
EnhancedSecureCryptoUtils.zeroizeBuffer(null);
|
||||||
|
EnhancedSecureCryptoUtils.zeroizeBuffer(undefined);
|
||||||
|
EnhancedSecureCryptoUtils.zeroizeBuffer(new ArrayBuffer(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('key-derivation-compat.test.mjs: all assertions passed');
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// End-to-end key agreement, using the REAL key generator and the REAL
|
||||||
|
// derivation — no hand-rolled CryptoKeys.
|
||||||
|
//
|
||||||
|
// This exists because key-derivation-compat.test.mjs did not catch a bug that
|
||||||
|
// broke every connection: it generated its own key pairs with
|
||||||
|
// ['deriveKey','deriveBits'] usages, while generateECDHKeyPair() produced keys
|
||||||
|
// with only ['deriveKey']. deriveBits() then failed with an InvalidAccessError
|
||||||
|
// on the real object, and no session could be established. A test that builds
|
||||||
|
// its own inputs verifies the algorithm; only a test that uses the shipped
|
||||||
|
// factory verifies the code.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
globalThis.window = { document: {} };
|
||||||
|
|
||||||
|
const { EnhancedSecureCryptoUtils } = await import('../src/crypto/EnhancedSecureCryptoUtils.js');
|
||||||
|
|
||||||
|
// ── the generated key pair must carry the usages the derivation needs ────────
|
||||||
|
{
|
||||||
|
const pair = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
assert.ok(pair.privateKey.usages.includes('deriveBits'),
|
||||||
|
'deriveSharedKeys() calls deriveBits — the private key must permit it');
|
||||||
|
assert.equal(pair.privateKey.extractable, false, 'the private key must stay non-extractable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── two peers derive identical session material ──────────────────────────────
|
||||||
|
{
|
||||||
|
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const salt = EnhancedSecureCryptoUtils.generateSalt();
|
||||||
|
assert.equal(salt.length, 64, 'the derivation requires a 64-byte salt');
|
||||||
|
|
||||||
|
const aliceKeys = await EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, bob.publicKey, salt);
|
||||||
|
const bobKeys = await EnhancedSecureCryptoUtils.deriveSharedKeys(bob.privateKey, alice.publicKey, salt);
|
||||||
|
|
||||||
|
// The fingerprint is what the SAS is built from: if the two sides disagree
|
||||||
|
// here, the safety codes differ and the users cannot complete verification.
|
||||||
|
assert.equal(aliceKeys.fingerprint, bobKeys.fingerprint,
|
||||||
|
'both peers must derive the same key fingerprint');
|
||||||
|
assert.match(aliceKeys.fingerprint, /^([0-9a-f]{2}:){11}[0-9a-f]{2}$/,
|
||||||
|
'fingerprint format must stay stable (it is displayed and fed to _computeSAS)');
|
||||||
|
|
||||||
|
for (const [name, keys] of [['alice', aliceKeys], ['bob', bobKeys]]) {
|
||||||
|
for (const field of ['messageKey', 'macKey', 'pfsKey', 'metadataKey']) {
|
||||||
|
assert.ok(keys[field] instanceof CryptoKey, `${name}.${field} must be a CryptoKey`);
|
||||||
|
assert.equal(keys[field].extractable, false, `${name}.${field} must be non-extractable`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── and the derived keys actually interoperate ───────────────────────────
|
||||||
|
// Matching fingerprints could in principle come from matching inputs to a
|
||||||
|
// broken derivation; encrypting on one side and decrypting on the other is
|
||||||
|
// the property the chat depends on.
|
||||||
|
const encrypted = await EnhancedSecureCryptoUtils.encryptMessage(
|
||||||
|
'hello from alice', aliceKeys.messageKey, aliceKeys.macKey, aliceKeys.metadataKey, 'msg_1', 0
|
||||||
|
);
|
||||||
|
const decrypted = await EnhancedSecureCryptoUtils.decryptMessage(
|
||||||
|
encrypted, bobKeys.messageKey, bobKeys.macKey, bobKeys.metadataKey, 0
|
||||||
|
);
|
||||||
|
assert.equal(decrypted.message, 'hello from alice', 'bob must decrypt what alice encrypted');
|
||||||
|
assert.equal(decrypted.messageId, 'msg_1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a different salt must give different keys ────────────────────────────────
|
||||||
|
// Guards against the salt being dropped from the HKDF inputs, which would make
|
||||||
|
// every session with the same peer derive the same keys.
|
||||||
|
{
|
||||||
|
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
|
||||||
|
const first = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||||
|
alice.privateKey, bob.publicKey, EnhancedSecureCryptoUtils.generateSalt());
|
||||||
|
const second = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||||
|
alice.privateKey, bob.publicKey, EnhancedSecureCryptoUtils.generateSalt());
|
||||||
|
|
||||||
|
assert.notEqual(first.fingerprint, second.fingerprint,
|
||||||
|
'a fresh salt must produce fresh session material');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a mismatched peer key must not yield a shared secret ─────────────────────
|
||||||
|
{
|
||||||
|
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const mallory = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const salt = EnhancedSecureCryptoUtils.generateSalt();
|
||||||
|
|
||||||
|
const aliceWithBob = await EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, bob.publicKey, salt);
|
||||||
|
const aliceWithMallory = await EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, mallory.publicKey, salt);
|
||||||
|
|
||||||
|
assert.notEqual(aliceWithBob.fingerprint, aliceWithMallory.fingerprint,
|
||||||
|
'a substituted public key must change the fingerprint — this is what the SAS surfaces');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the derivation rejects malformed inputs rather than degrading ────────────
|
||||||
|
{
|
||||||
|
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, bob.publicKey, new Array(32).fill(1)),
|
||||||
|
/64 bytes/,
|
||||||
|
'a short salt must be refused'
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => EnhancedSecureCryptoUtils.deriveSharedKeys('not-a-key', bob.publicKey, EnhancedSecureCryptoUtils.generateSalt()),
|
||||||
|
/private key/i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('key-exchange-e2e.test.mjs: all assertions passed');
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// The reference-QR flow used to persist whole session offers — SDP with every
|
||||||
|
// ICE candidate, both public keys, the session salt and the SAS code — under
|
||||||
|
// `qr_offer_<id>`, and nothing ever deleted them. Removing the writer stops the
|
||||||
|
// bleeding; these assertions cover the other half, that an upgrade also clears
|
||||||
|
// what earlier versions already wrote to disk.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const bootSource = readFileSync(new URL('../src/scripts/app-boot.js', import.meta.url), 'utf8');
|
||||||
|
const appSource = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
// ── nothing writes offer payloads to localStorage any more ───────────────────
|
||||||
|
assert.equal(
|
||||||
|
/localStorage\.setItem\(\s*[`'"]qr_offer_/.test(appSource),
|
||||||
|
false,
|
||||||
|
'no code may persist a session offer to localStorage'
|
||||||
|
);
|
||||||
|
// Match a definition, not the identifier: the explanatory note left in its place
|
||||||
|
// names the removed function on purpose, and that note is worth keeping.
|
||||||
|
assert.equal(
|
||||||
|
/(const|let|var|function)\s+createQRReference\b/.test(appSource),
|
||||||
|
false,
|
||||||
|
'the reference-QR writer must stay removed'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── and the purge runs at startup ────────────────────────────────────────────
|
||||||
|
assert.ok(bootSource.includes('purgeLegacyOfferRecords'), 'startup must purge legacy records');
|
||||||
|
|
||||||
|
// Exercise the real behaviour against a localStorage stand-in.
|
||||||
|
const store = new Map([
|
||||||
|
['qr_offer_offer_1700000000000_abc123', '{"sdp":"v=0...","salt":[1,2,3]}'],
|
||||||
|
['qr_offer_offer_1700000000001_def456', '{"sdp":"v=0..."}'],
|
||||||
|
['securebit_my_status', 'available'],
|
||||||
|
['securebit_relay_only_mode', 'true'],
|
||||||
|
['app_version', '5.6.1']
|
||||||
|
]);
|
||||||
|
|
||||||
|
globalThis.localStorage = {
|
||||||
|
get length() { return store.size; },
|
||||||
|
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||||
|
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||||
|
setItem: (k, v) => { store.set(k, String(v)); },
|
||||||
|
removeItem: (k) => { store.delete(k); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract and run the purge exactly as shipped, rather than reimplementing it —
|
||||||
|
// a copy in the test would keep passing after the real one drifted.
|
||||||
|
const fnSource = bootSource.slice(
|
||||||
|
bootSource.indexOf('const purgeLegacyOfferRecords'),
|
||||||
|
bootSource.indexOf('// Mount application once DOM and modules are ready')
|
||||||
|
);
|
||||||
|
const purge = new Function(`${fnSource}; return purgeLegacyOfferRecords;`)();
|
||||||
|
|
||||||
|
purge();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(store.keys()).filter((k) => k.startsWith('qr_offer_')),
|
||||||
|
[],
|
||||||
|
'every legacy offer record must be removed'
|
||||||
|
);
|
||||||
|
// Deleting while iterating by index is easy to get wrong — it shifts the
|
||||||
|
// remaining entries and silently skips every other key. Assert survivors too.
|
||||||
|
assert.equal(store.get('securebit_my_status'), 'available', 'user settings must survive');
|
||||||
|
assert.equal(store.get('securebit_relay_only_mode'), 'true', 'user settings must survive');
|
||||||
|
assert.equal(store.get('app_version'), '5.6.1', 'version tracking must survive');
|
||||||
|
|
||||||
|
console.log('legacy-offer-purge.test.mjs: all assertions passed');
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// A view-once or disappearing message must never have its text handed to the
|
||||||
|
// operating system. Notifications only fire while the tab is backgrounded — so
|
||||||
|
// in practice onto a lock screen — and once the OS holds the text it persists in
|
||||||
|
// the notification centre, in device backups and on the user's other synced
|
||||||
|
// devices, where the app can no longer delete it. The message the UI destroys
|
||||||
|
// after 30 seconds would outlive itself indefinitely.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'https://localhost/' });
|
||||||
|
globalThis.window = dom.window;
|
||||||
|
globalThis.document = dom.window.document;
|
||||||
|
globalThis.Notification = dom.window.Notification = class {
|
||||||
|
static permission = 'granted';
|
||||||
|
static requestPermission() { return Promise.resolve('granted'); }
|
||||||
|
close() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await import('../src/notifications/NotificationIntegration.js');
|
||||||
|
const NotificationIntegration = window.NotificationIntegration;
|
||||||
|
|
||||||
|
const SECRET = 'the account password is hunter2';
|
||||||
|
|
||||||
|
const setup = async () => {
|
||||||
|
const manager = {
|
||||||
|
onMessage: () => {},
|
||||||
|
onStatusChange: () => {},
|
||||||
|
deliverMessageToUI: () => {}
|
||||||
|
};
|
||||||
|
const integration = new NotificationIntegration(manager);
|
||||||
|
await integration.init();
|
||||||
|
|
||||||
|
const notified = [];
|
||||||
|
integration.notificationManager.notify = (senderName, text, options) => {
|
||||||
|
notified.push({ senderName, text, options });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// The real manager suppresses notifications while the tab is focused; these
|
||||||
|
// assertions are about what it is ASKED to show, so bypass that.
|
||||||
|
integration.notificationManager.isTabActive = false;
|
||||||
|
return { manager, integration, notified };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── ephemeral messages: the OS learns that something arrived, not what ───────
|
||||||
|
for (const [label, meta] of [
|
||||||
|
['view-once', { mid: 'm1', once: true, onceTtl: 15 }],
|
||||||
|
['disappearing', { mid: 'm2', ttl: 30 }],
|
||||||
|
['both', { mid: 'm3', once: true, onceTtl: 15, ttl: 30 }]
|
||||||
|
]) {
|
||||||
|
const { manager, notified } = await setup();
|
||||||
|
manager.onMessage(SECRET, 'received', meta);
|
||||||
|
|
||||||
|
assert.equal(notified.length, 1, `${label}: a notification must still be shown`);
|
||||||
|
assert.equal(
|
||||||
|
notified[0].text.includes('hunter2'), false,
|
||||||
|
`${label}: the message text must not reach the OS notification`
|
||||||
|
);
|
||||||
|
assert.ok(notified[0].text.length > 0, `${label}: but the user must still be told something arrived`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ordinary messages keep their preview ─────────────────────────────────────
|
||||||
|
// Suppressing everything would be the easy fix and the wrong one: it would make
|
||||||
|
// notifications useless and invite someone to revert this.
|
||||||
|
{
|
||||||
|
const { manager, notified } = await setup();
|
||||||
|
manager.onMessage(SECRET, 'received', { mid: 'm4', ts: Date.now() });
|
||||||
|
assert.equal(notified.length, 1);
|
||||||
|
assert.ok(notified[0].text.includes('hunter2'), 'a normal message keeps its preview');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a message with no meta at all is treated as ordinary ─────────────────────
|
||||||
|
{
|
||||||
|
const { manager, notified } = await setup();
|
||||||
|
manager.onMessage(SECRET, 'received');
|
||||||
|
assert.equal(notified.length, 1);
|
||||||
|
assert.ok(notified[0].text.includes('hunter2'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the deliverMessageToUI wrapper must apply the same rule ──────────────────
|
||||||
|
// It is a second, independent entry point into the same notification path; the
|
||||||
|
// original bug existed on both.
|
||||||
|
{
|
||||||
|
const { manager, notified } = await setup();
|
||||||
|
manager.deliverMessageToUI(SECRET, 'received', { mid: 'm5', once: true });
|
||||||
|
assert.equal(notified.length, 1);
|
||||||
|
assert.equal(notified[0].text.includes('hunter2'), false,
|
||||||
|
'deliverMessageToUI must suppress ephemeral previews too');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('notification-ephemeral-privacy.test.mjs: all assertions passed');
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// A scanned QR code is fully attacker-controlled input, and DEFLATE compresses
|
||||||
|
// repetitive data ~1000:1 — so a QR small enough to print on a sticker can
|
||||||
|
// expand to hundreds of megabytes and OOM-kill the tab, taking the live session
|
||||||
|
// and its keys with it.
|
||||||
|
//
|
||||||
|
// The subtlety this file exists for: pako documents a `maxOutputLength` option,
|
||||||
|
// and pako 2.1.0 SILENTLY IGNORES IT. Passing it looks like a fix, passes review
|
||||||
|
// and does nothing. These assertions pin the behaviour we actually depend on.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import pako from 'pako';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('../src/crypto/cose-qr.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
// ── the option we cannot rely on ─────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const bomb = pako.deflate(new Uint8Array(8 * 1024 * 1024)); // 8 MB of zeros
|
||||||
|
assert.ok(bomb.length < 64 * 1024, 'sanity: the bomb really is small compressed');
|
||||||
|
|
||||||
|
const ignored = pako.inflate(bomb, { maxOutputLength: 256 * 1024 });
|
||||||
|
// If this ever starts throwing (or truncating), pako has gained real support
|
||||||
|
// and inflateBounded could be simplified — but only then, deliberately.
|
||||||
|
assert.equal(ignored.length, 8 * 1024 * 1024,
|
||||||
|
'pako still ignores maxOutputLength; the streaming guard is load-bearing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the QR path must not use the one-shot helper ─────────────────────────────
|
||||||
|
assert.equal(
|
||||||
|
/pako\.inflate\(/.test(source), false,
|
||||||
|
'the one-shot pako.inflate does not bound output and must not be used on QR input'
|
||||||
|
);
|
||||||
|
assert.ok(/new pako\.Inflate\(/.test(source), 'the streaming API is what enforces the bound');
|
||||||
|
|
||||||
|
// ── the real helper, exercised as shipped ────────────────────────────────────
|
||||||
|
const helperSource = source.slice(
|
||||||
|
source.indexOf('const MAX_INFLATED_QR_BYTES'),
|
||||||
|
source.indexOf('// Generate UUID for chunking')
|
||||||
|
);
|
||||||
|
const inflateBounded = new Function('pako', `${helperSource}; return inflateBounded;`)(pako);
|
||||||
|
|
||||||
|
{
|
||||||
|
const bomb = pako.deflate(new Uint8Array(8 * 1024 * 1024));
|
||||||
|
assert.throws(
|
||||||
|
() => inflateBounded(bomb, 'test'),
|
||||||
|
/expands beyond/,
|
||||||
|
'a zip bomb must be refused'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A normal offer still round-trips — a bound that breaks decompression would be
|
||||||
|
// removed by the next person who hits it.
|
||||||
|
{
|
||||||
|
const payload = JSON.stringify({ type: 'enhanced_secure_offer', sdp: 'v=0\r\n'.repeat(200) });
|
||||||
|
const restored = inflateBounded(pako.deflate(new TextEncoder().encode(payload)), 'test');
|
||||||
|
assert.equal(new TextDecoder().decode(restored), payload, 'a real offer must decompress intact');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right below the ceiling is fine; just above it is not.
|
||||||
|
{
|
||||||
|
const under = new Uint8Array(200 * 1024).map((_, i) => i % 251);
|
||||||
|
assert.equal(inflateBounded(pako.deflate(under), 'test').length, under.length);
|
||||||
|
|
||||||
|
const over = new Uint8Array(300 * 1024).map((_, i) => i % 251);
|
||||||
|
assert.throws(() => inflateBounded(pako.deflate(over), 'test'), /expands beyond/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garbage input fails cleanly rather than hanging or returning junk.
|
||||||
|
{
|
||||||
|
assert.throws(
|
||||||
|
() => inflateBounded(new Uint8Array([1, 2, 3, 4, 5]), 'test'),
|
||||||
|
/could not be decompressed/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('qr-zip-bomb.test.mjs: all assertions passed');
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// The ratchet wired into the manager, not in isolation.
|
||||||
|
//
|
||||||
|
// double-ratchet.test.mjs proves the algorithm. This proves the wiring: that the
|
||||||
|
// handshake actually starts a ratchet, that a frame produced by the send path is
|
||||||
|
// readable by the receive path, and — the part most likely to be got wrong —
|
||||||
|
// that a peer which does not support it degrades to the previous scheme instead
|
||||||
|
// of failing to communicate at all.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
globalThis.window = { document: {} };
|
||||||
|
const { EnhancedSecureCryptoUtils } = await import('../src/crypto/EnhancedSecureCryptoUtils.js');
|
||||||
|
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
|
||||||
|
|
||||||
|
globalThis.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i?.detail; } };
|
||||||
|
globalThis.document = { dispatchEvent() {} };
|
||||||
|
|
||||||
|
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
||||||
|
const P = EnhancedSecureWebRTCManager.prototype;
|
||||||
|
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-import a public key the way the handshake delivers it. importSignedPublicKey
|
||||||
|
* imports SPKI as NON-EXTRACTABLE, while a locally generated public key is always
|
||||||
|
* extractable — so handing `keyPair.publicKey` straight to the manager tests a
|
||||||
|
* key shape the app never sees. A ratchet-setup failure that hit only the
|
||||||
|
* initiator got through review precisely because the test used the easy shape.
|
||||||
|
*/
|
||||||
|
async function asReceivedFromPeer(publicKey) {
|
||||||
|
const spki = await crypto.subtle.exportKey('spki', publicKey);
|
||||||
|
const imported = await crypto.subtle.importKey(
|
||||||
|
'spki', spki, { name: 'ECDH', namedCurve: 'P-384' }, false, []
|
||||||
|
);
|
||||||
|
assert.equal(imported.extractable, false);
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handshake({ initiatorSupports = true, responderSupports = true } = {}) {
|
||||||
|
const initiatorKeys = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const responderKeys = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const salt = EnhancedSecureCryptoUtils.generateSalt();
|
||||||
|
|
||||||
|
// What each side actually holds for the other, post-handshake.
|
||||||
|
const initiatorPeerKey = await asReceivedFromPeer(responderKeys.publicKey);
|
||||||
|
const responderPeerKey = await asReceivedFromPeer(initiatorKeys.publicKey);
|
||||||
|
|
||||||
|
const make = (own, peerPub, isInitiator, peerSupports) => {
|
||||||
|
const delivered = [];
|
||||||
|
const mgr = {
|
||||||
|
delivered,
|
||||||
|
ecdhKeyPair: own,
|
||||||
|
peerPublicKey: peerPub,
|
||||||
|
sessionSalt: salt,
|
||||||
|
securityFeatures: {},
|
||||||
|
_peerSupportsRatchet: peerSupports,
|
||||||
|
_ratchet: null,
|
||||||
|
_secureLog() {},
|
||||||
|
_checkInboundRateLimit: () => true,
|
||||||
|
deliverMessageToUI: (m, type, meta) => delivered.push({ m, type, meta }),
|
||||||
|
_initializeRatchet: P._initializeRatchet,
|
||||||
|
isRatchetActive: P.isRatchetActive,
|
||||||
|
_processRatchetMessage: P._processRatchetMessage
|
||||||
|
};
|
||||||
|
return { mgr, isInitiator };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both sides derive from the same ECDH, exactly as the handshake does.
|
||||||
|
const initiatorDerived = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||||
|
initiatorKeys.privateKey, responderKeys.publicKey, salt);
|
||||||
|
const responderDerived = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||||
|
responderKeys.privateKey, initiatorKeys.publicKey, salt);
|
||||||
|
|
||||||
|
assert.equal(initiatorDerived.fingerprint, responderDerived.fingerprint,
|
||||||
|
'sanity: the handshake must agree before the ratchet is layered on');
|
||||||
|
|
||||||
|
const a = make(initiatorKeys, initiatorPeerKey, true, responderSupports);
|
||||||
|
const b = make(responderKeys, responderPeerKey, false, initiatorSupports);
|
||||||
|
|
||||||
|
await a.mgr._initializeRatchet(initiatorDerived, true);
|
||||||
|
await b.mgr._initializeRatchet(responderDerived, false);
|
||||||
|
|
||||||
|
return { a: a.mgr, b: b.mgr };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a frame the way sendSecureMessage does, and hand it to the real
|
||||||
|
// receive path rather than calling the ratchet directly.
|
||||||
|
const sendThrough = async (from, to, text) => {
|
||||||
|
const envelope = JSON.stringify({ type: 'message', data: text });
|
||||||
|
const { header, ciphertext } = await from._ratchet.encrypt(envelope);
|
||||||
|
await to._processRatchetMessage({ type: T.RATCHET_MESSAGE, h: header, c: ciphertext, version: '5.0' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── both sides support it: a ratchet comes up and carries chat ───────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await handshake();
|
||||||
|
assert.equal(a.isRatchetActive(), true, 'the initiator must start a ratchet');
|
||||||
|
assert.equal(b.isRatchetActive(), true, 'the responder must start a ratchet');
|
||||||
|
assert.equal(a.securityFeatures.hasPFS, true);
|
||||||
|
|
||||||
|
await sendThrough(a, b, 'hello from the initiator');
|
||||||
|
assert.deepEqual(b.delivered.at(-1).m, 'hello from the initiator');
|
||||||
|
assert.equal(b.delivered.at(-1).type, 'received');
|
||||||
|
|
||||||
|
await sendThrough(b, a, 'hello back');
|
||||||
|
assert.equal(a.delivered.at(-1).m, 'hello back');
|
||||||
|
|
||||||
|
// Several turns, so the DH ratchet steps more than once.
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
await sendThrough(a, b, `a${i}`);
|
||||||
|
await sendThrough(b, a, `b${i}`);
|
||||||
|
}
|
||||||
|
assert.equal(b.delivered.at(-1).m, 'a5');
|
||||||
|
assert.equal(a.delivered.at(-1).m, 'b5');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the responder can speak before the initiator does ───────────────────────
|
||||||
|
// The Double Ratchet gives the responder no sending chain until it has seen the
|
||||||
|
// initiator's ratchet key — but the app pushes a presence update from BOTH sides
|
||||||
|
// the moment verification completes. If the send path assumed a usable ratchet,
|
||||||
|
// the responder's first frame would throw and its presence would never go out.
|
||||||
|
{
|
||||||
|
const { a, b } = await handshake();
|
||||||
|
|
||||||
|
assert.equal(b.isRatchetActive(), true, 'the responder still HAS a ratchet...');
|
||||||
|
assert.equal(b._ratchet.canEncrypt, false, '...it just cannot send on it yet');
|
||||||
|
assert.equal(a._ratchet.canEncrypt, true, 'the initiator can send immediately');
|
||||||
|
|
||||||
|
// Once the initiator speaks, the responder gains its sending chain.
|
||||||
|
await sendThrough(a, b, 'first');
|
||||||
|
assert.equal(b._ratchet.canEncrypt, true, 'receiving must open the responder’s sending chain');
|
||||||
|
await sendThrough(b, a, 'reply');
|
||||||
|
assert.equal(a.delivered.at(-1).m, 'reply');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── per-message metadata still reaches the UI ────────────────────────────────
|
||||||
|
// view-once / disappearing ride inside the encrypted envelope; losing them here
|
||||||
|
// would silently turn ephemeral messages into permanent ones.
|
||||||
|
{
|
||||||
|
const { a, b } = await handshake();
|
||||||
|
const envelope = JSON.stringify({ type: 'message', data: 'burn after reading', meta: { mid: 'm1', once: true } });
|
||||||
|
const { header, ciphertext } = await a._ratchet.encrypt(envelope);
|
||||||
|
await b._processRatchetMessage({ type: T.RATCHET_MESSAGE, h: header, c: ciphertext });
|
||||||
|
|
||||||
|
assert.equal(b.delivered.at(-1).m, 'burn after reading');
|
||||||
|
assert.deepEqual(b.delivered.at(-1).meta, { mid: 'm1', once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NEGOTIATION: a peer on an older build must still be able to talk ─────────
|
||||||
|
// This is the compatibility guarantee. If either side does not advertise the
|
||||||
|
// ratchet, neither may start one — a one-sided ratchet decrypts nothing.
|
||||||
|
{
|
||||||
|
const { a, b } = await handshake({ responderSupports: false, initiatorSupports: false });
|
||||||
|
assert.equal(a.isRatchetActive(), false, 'no ratchet when the peer did not advertise it');
|
||||||
|
assert.equal(b.isRatchetActive(), false);
|
||||||
|
assert.equal(a.securityFeatures.hasPFS, undefined,
|
||||||
|
'and the PFS flag must not be raised for a session that does not have it');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a half-negotiated session must not half-enable ───────────────────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await handshake({ responderSupports: false, initiatorSupports: true });
|
||||||
|
assert.equal(a.isRatchetActive(), false, 'initiator saw no support in the answer');
|
||||||
|
assert.equal(b.isRatchetActive(), true, 'responder saw support in the offer');
|
||||||
|
|
||||||
|
// The asymmetric case cannot happen in practice — both flags come from the
|
||||||
|
// same pair of packages — but if it ever did, the ratcheted side must not be
|
||||||
|
// able to push frames the other cannot read. The receiving side simply has
|
||||||
|
// no ratchet and drops them rather than crashing.
|
||||||
|
const envelope = JSON.stringify({ type: 'message', data: 'unreadable' });
|
||||||
|
const { header, ciphertext } = await b._ratchet.encrypt(envelope).catch(() => ({}));
|
||||||
|
if (header) {
|
||||||
|
await a._processRatchetMessage({ type: T.RATCHET_MESSAGE, h: header, c: ciphertext });
|
||||||
|
assert.deepEqual(a.delivered, [], 'a frame we cannot decrypt must be dropped, not rendered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── malformed frames are dropped without throwing ────────────────────────────
|
||||||
|
{
|
||||||
|
const { a, b } = await handshake();
|
||||||
|
for (const frame of [
|
||||||
|
{ type: T.RATCHET_MESSAGE },
|
||||||
|
{ type: T.RATCHET_MESSAGE, h: 'not json', c: 'AAAA' },
|
||||||
|
{ type: T.RATCHET_MESSAGE, h: JSON.stringify({ dh: 'x', pn: 0, n: 0 }), c: '!!!not base64!!!' },
|
||||||
|
{ type: T.RATCHET_MESSAGE, h: 123, c: 456 }
|
||||||
|
]) {
|
||||||
|
await b._processRatchetMessage(frame);
|
||||||
|
}
|
||||||
|
assert.deepEqual(b.delivered, [], 'nothing malformed may reach the UI');
|
||||||
|
|
||||||
|
// And the session still works afterwards.
|
||||||
|
await sendThrough(a, b, 'still fine');
|
||||||
|
assert.equal(b.delivered.at(-1).m, 'still fine');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the ratchet root is domain-separated from the session keys ───────────────
|
||||||
|
// Learning a message key must tell an attacker nothing about the ratchet root.
|
||||||
|
{
|
||||||
|
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||||
|
const salt = EnhancedSecureCryptoUtils.generateSalt();
|
||||||
|
const derived = await EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, bob.publicKey, salt);
|
||||||
|
|
||||||
|
assert.ok(derived.ratchetRoot instanceof Uint8Array, 'a ratchet root must be produced');
|
||||||
|
assert.equal(derived.ratchetRoot.length, 32);
|
||||||
|
assert.ok(derived.ratchetRoot.some((b) => b !== 0), 'and it must not be all zeros');
|
||||||
|
|
||||||
|
// A different salt gives a different root, so two sessions never share state.
|
||||||
|
const other = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||||
|
alice.privateKey, bob.publicKey, EnhancedSecureCryptoUtils.generateSalt());
|
||||||
|
assert.notDeepEqual(Array.from(derived.ratchetRoot), Array.from(other.ratchetRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ratchet-integration.test.mjs: all assertions passed');
|
||||||
@@ -231,16 +231,15 @@ function createVerificationReadinessManager({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const originalTimeout = EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT;
|
// Both budgets are passed explicitly: gathering now waits past the soft
|
||||||
EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT = 0;
|
// deadline while there is nothing at all to export (see
|
||||||
try {
|
// ice-gathering-patience.test.mjs), so relying on the default hard ceiling
|
||||||
assert.equal(
|
// here would stall this assertion for 25 s. The property under test is
|
||||||
await EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager),
|
// unchanged — a timeout must report false, never "complete".
|
||||||
false
|
assert.equal(
|
||||||
);
|
await EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager, 0, 0),
|
||||||
} finally {
|
false
|
||||||
EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT = originalTimeout;
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A timed-out ICE gathering can still yield usable candidates for manual export.
|
// A timed-out ICE gathering can still yield usable candidates for manual export.
|
||||||
|
|||||||
@@ -48,18 +48,33 @@ const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
|
|||||||
assert.equal(calls[0].meta, undefined);
|
assert.equal(calls[0].meta, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── processMessage routes message_delete to onMessageDelete ──────────────────
|
// ── message_delete routes to onMessageDelete, but only once verified ─────────
|
||||||
|
// Unsend lets the peer remove a message from OUR transcript, so it is a control
|
||||||
|
// frame: acting on it before the SAS has been compared would let anyone who
|
||||||
|
// completed the handshake — a MITM included — edit what the user sees. It used
|
||||||
|
// to be honoured unconditionally.
|
||||||
{
|
{
|
||||||
const deleted = [];
|
const makeManager = (isVerified) => {
|
||||||
const manager = {
|
const deleted = [];
|
||||||
_secureLog() {},
|
return {
|
||||||
onMessageDelete: (id) => deleted.push(id)
|
deleted,
|
||||||
|
manager: {
|
||||||
|
isVerified,
|
||||||
|
_secureLog() {},
|
||||||
|
_enforceVerificationGate: P._enforceVerificationGate,
|
||||||
|
onMessageDelete: (id) => deleted.push(id)
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
await P.processMessage.call(
|
const frame = JSON.stringify({ type: T.MESSAGE_DELETE, data: { messageId: 'm_42' } });
|
||||||
manager,
|
|
||||||
JSON.stringify({ type: T.MESSAGE_DELETE, data: { messageId: 'm_42' } })
|
const before = makeManager(false);
|
||||||
);
|
await P.processMessage.call(before.manager, frame);
|
||||||
assert.deepEqual(deleted, ['m_42']);
|
assert.deepEqual(before.deleted, [], 'an unverified peer must not delete our messages');
|
||||||
|
|
||||||
|
const after = makeManager(true);
|
||||||
|
await P.processMessage.call(after.manager, frame);
|
||||||
|
assert.deepEqual(after.deleted, ['m_42'], 'unsend still works on a verified session');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── live enhanced-message path delivers metadata to the UI ───────────────────
|
// ── live enhanced-message path delivers metadata to the UI ───────────────────
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ function createManager(overrides = {}) {
|
|||||||
encryptionKey: {},
|
encryptionKey: {},
|
||||||
hmacKey: {},
|
hmacKey: {},
|
||||||
replayProtectionEnabled: true,
|
replayProtectionEnabled: true,
|
||||||
|
// Our own fingerprint and the peer's are tracked separately; the SAS binds
|
||||||
|
// both, so the dtlsFingerprint flag requires both to be present.
|
||||||
expectedDTLSFingerprint: 'aa:bb',
|
expectedDTLSFingerprint: 'aa:bb',
|
||||||
|
_peerDTLSFingerprint: 'cc:dd',
|
||||||
verificationCode: '1234567',
|
verificationCode: '1234567',
|
||||||
|
localVerificationConfirmed: true,
|
||||||
|
isRatchetActive: () => true,
|
||||||
connectionId: 'conn-1',
|
connectionId: 'conn-1',
|
||||||
keyFingerprint: 'ff:ee',
|
keyFingerprint: 'ff:ee',
|
||||||
_secureLog() {},
|
_secureLog() {},
|
||||||
@@ -63,6 +68,34 @@ function createManager(overrides = {}) {
|
|||||||
assert.equal(data.score + '%', '90%');
|
assert.equal(data.score + '%', '90%');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the flags report what was verified, not what was merely computed ─────────
|
||||||
|
// A SAS code exists the moment the handshake completes — including for a MITM's
|
||||||
|
// session. It only means anything once the USER has compared it out of band, so
|
||||||
|
// the flag has to track the confirmation and not the code's existence. Likewise,
|
||||||
|
// holding our own DTLS fingerprint proves nothing without the peer's: the SAS
|
||||||
|
// binds the pair.
|
||||||
|
{
|
||||||
|
const unconfirmed = await createManager({ localVerificationConfirmed: false }).getRealSecurityLevel();
|
||||||
|
assert.equal(unconfirmed.sasCode, false, 'an uncompared SAS code is not authentication');
|
||||||
|
|
||||||
|
const halfFingerprint = await createManager({ _peerDTLSFingerprint: null }).getRealSecurityLevel();
|
||||||
|
assert.equal(halfFingerprint.dtlsFingerprint, false, 'our own fingerprint alone proves nothing');
|
||||||
|
|
||||||
|
// PFS tracks whether the Double Ratchet is actually running on THIS
|
||||||
|
// connection. A peer on an older build negotiates it away, and the panel has
|
||||||
|
// to show that rather than the capability we happen to ship.
|
||||||
|
const withRatchet = await createManager().getRealSecurityLevel();
|
||||||
|
assert.equal(withRatchet.perfectForwardSecrecy, true, 'an active ratchet must be reported');
|
||||||
|
|
||||||
|
const withoutRatchet = await createManager({ isRatchetActive: () => false }).getRealSecurityLevel();
|
||||||
|
assert.equal(withoutRatchet.perfectForwardSecrecy, false,
|
||||||
|
'a session that fell back to static keys must not claim forward secrecy');
|
||||||
|
|
||||||
|
// A status report must never throw, even on a partially built manager.
|
||||||
|
const partial = await createManager({ isRatchetActive: undefined }).getRealSecurityLevel();
|
||||||
|
assert.equal(partial.perfectForwardSecrecy, false, 'unknown must read as off, not crash');
|
||||||
|
}
|
||||||
|
|
||||||
// ── not-ready path is flagged, not rendered as a real measurement ────────────
|
// ── not-ready path is flagged, not rendered as a real measurement ────────────
|
||||||
{
|
{
|
||||||
// calculateAndReportSecurityLevel returns null when the session is not yet
|
// calculateAndReportSecurityLevel returns null when the session is not yet
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Voice notes are the only transfer accepted without asking the user. The flag
|
||||||
|
// that grants that exemption, `isVoice`, is set by the SENDER and sits outside
|
||||||
|
// the signed fileHash on purpose — so it is the receiver's job to decide whether
|
||||||
|
// a transfer has actually earned the exemption.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
globalThis.window = {};
|
||||||
|
|
||||||
|
const { EnhancedSecureFileTransfer } = await import('../src/transfer/EnhancedSecureFileTransfer.js');
|
||||||
|
|
||||||
|
const makeTransfer = () => new EnhancedSecureFileTransfer({
|
||||||
|
dataChannel: { readyState: 'open', send() {} },
|
||||||
|
encryptionKey: null,
|
||||||
|
macKey: null,
|
||||||
|
onProgress() {},
|
||||||
|
onFileReceived() {},
|
||||||
|
onError() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataFor = (over = {}) => ({
|
||||||
|
fileId: 'f1',
|
||||||
|
fileName: 'voice-message.webm',
|
||||||
|
fileSize: 48_000,
|
||||||
|
fileType: 'audio/webm',
|
||||||
|
totalChunks: 3,
|
||||||
|
chunkSize: 16 * 1024,
|
||||||
|
salt: Array.from({ length: 32 }, (_, i) => i),
|
||||||
|
isVoice: true,
|
||||||
|
...over
|
||||||
|
});
|
||||||
|
|
||||||
|
const ft = makeTransfer();
|
||||||
|
|
||||||
|
// ── a genuine voice note still skips the consent card ────────────────────────
|
||||||
|
{
|
||||||
|
const v = ft.validateIncomingMetadata(metadataFor());
|
||||||
|
assert.equal(v.isValid, true, v.errors.join('; '));
|
||||||
|
assert.equal(v.isVoice, true, 'a real voice note must keep auto-accept');
|
||||||
|
assert.equal(v.voiceRejection, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── an arbitrary blob wearing an allowed extension must not ──────────────────
|
||||||
|
// This was the actual hole: `.mp4` is in the voice extension list and
|
||||||
|
// application/octet-stream counts as a "generic" MIME for ordinary uploads, so a
|
||||||
|
// 20 MB blob claiming isVoice was downloaded and rendered with no prompt at all.
|
||||||
|
{
|
||||||
|
const v = ft.validateIncomingMetadata(metadataFor({
|
||||||
|
fileName: 'payload.mp4',
|
||||||
|
fileType: 'application/octet-stream',
|
||||||
|
fileSize: 20 * 1024 * 1024
|
||||||
|
}));
|
||||||
|
assert.equal(v.isVoice, false, 'a generic-MIME blob must not auto-accept');
|
||||||
|
assert.match(v.voiceRejection, /not an audio MIME type/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── an absent MIME is not a free pass either ─────────────────────────────────
|
||||||
|
{
|
||||||
|
const v = ft.validateIncomingMetadata(metadataFor({ fileType: '' }));
|
||||||
|
assert.equal(v.isVoice, false);
|
||||||
|
assert.match(v.voiceRejection, /not an audio MIME type/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── audio, but not an audio type we actually support ─────────────────────────
|
||||||
|
{
|
||||||
|
const v = ft.validateIncomingMetadata(metadataFor({ fileType: 'audio/x-made-up' }));
|
||||||
|
assert.equal(v.isVoice, false);
|
||||||
|
assert.match(v.voiceRejection, /unsupported audio MIME type/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── size ceiling: a voice note is minutes of speech, not a payload channel ───
|
||||||
|
{
|
||||||
|
const v = ft.validateIncomingMetadata(metadataFor({
|
||||||
|
fileSize: ft.MAX_AUTO_ACCEPT_VOICE_SIZE + 1
|
||||||
|
}));
|
||||||
|
assert.equal(v.isVoice, false);
|
||||||
|
assert.match(v.voiceRejection, /too large to auto-accept/);
|
||||||
|
|
||||||
|
// Right at the ceiling is still fine.
|
||||||
|
const ok = ft.validateIncomingMetadata(metadataFor({
|
||||||
|
fileSize: ft.MAX_AUTO_ACCEPT_VOICE_SIZE
|
||||||
|
}));
|
||||||
|
assert.equal(ok.isVoice, true, 'the limit itself must be accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a per-session budget bounds the total, not just each one ─────────────────
|
||||||
|
{
|
||||||
|
const budgeted = makeTransfer();
|
||||||
|
budgeted.autoAcceptedVoiceBytes = budgeted.MAX_AUTO_ACCEPT_VOICE_SESSION_BYTES - 1000;
|
||||||
|
|
||||||
|
const v = budgeted.validateIncomingMetadata(metadataFor({ fileSize: 48_000 }));
|
||||||
|
assert.equal(v.isVoice, false, 'the session budget must eventually stop auto-accept');
|
||||||
|
assert.match(v.voiceRejection, /budget/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a rejected voice claim is a downgrade, not a drop ────────────────────────
|
||||||
|
// The peer may be sending something perfectly legitimate that simply does not
|
||||||
|
// qualify; it must still reach the user through the normal consent card.
|
||||||
|
{
|
||||||
|
const v = ft.validateIncomingMetadata(metadataFor({
|
||||||
|
fileName: 'report.pdf',
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
fileSize: 100_000
|
||||||
|
}));
|
||||||
|
assert.equal(v.isValid, true, 'the transfer itself stays valid');
|
||||||
|
assert.equal(v.isVoice, false, 'but it does not skip consent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── files that never claimed to be voice are unaffected ──────────────────────
|
||||||
|
{
|
||||||
|
const v = ft.validateIncomingMetadata(metadataFor({
|
||||||
|
fileName: 'photo.png', fileType: 'image/png', isVoice: undefined
|
||||||
|
}));
|
||||||
|
assert.equal(v.isValid, true, v.errors.join('; '));
|
||||||
|
assert.equal(v.isVoice, false);
|
||||||
|
assert.equal(v.voiceRejection, null, 'no rejection reason for something that never asked');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('voice-auto-accept.test.mjs: all assertions passed');
|
||||||
Reference in New Issue
Block a user