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

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

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

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

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

Also in this release:

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

Test suite grows from 27 to 41 files, covering forward secrecy, post-compromise
re-keying, out-of-order delivery across ratchet steps, the skipped-key bounds,
tamper resistance, negotiation fallback, and byte-level key-derivation
compatibility with 5.6.0.
This commit is contained in:
lockbitchat
2026-08-05 23:02:20 -04:00
parent 2a7142c722
commit 27279ae7c6
38 changed files with 4570 additions and 752 deletions
+18 -49
View File
@@ -3895,28 +3895,12 @@ import {
}
};
const createQRReference = (offerData) => {
try {
// Create a unique reference ID for this offer
const referenceId = `offer_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Store the full offer data in localStorage with the reference ID
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;
}
};
// NOTE: createQRReference() used to live here. It kept the invitation
// payload in localStorage and put only a reference id in the QR which
// could not work across devices, since the payload stayed on the
// sender's machine, and left records behind that nothing cleaned up.
// Removed rather than repaired; QR invitations travel as
// self-contained COSE payloads (see packSecurePayload).
const createTemplateOffer = (offer) => {
// Minimal template to keep QR within single image capacity
@@ -4550,33 +4534,18 @@ import {
setShowQRScannerModal(false); // Close QR scanner modal
return true;
}
// Check if this is a reference-based QR code
else if (parsedData.type === 'secure_offer_reference' && parsedData.referenceId) {
// Try to get the full offer data from localStorage
const fullOfferData = localStorage.getItem(`qr_offer_${parsedData.referenceId}`);
if (fullOfferData) {
const fullOffer = JSON.parse(fullOfferData);
// Determine which input to populate based on current mode
if (showOfferStep) {
// In "Waiting for peer's response" mode - populate answerInput
setAnswerInput(JSON.stringify(fullOffer, null, 2));
} else {
// In "Paste secure invitation" mode - populate offerInput
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;
}
// Reference-based QR codes are no longer produced or read:
// the payload they pointed at lived in the *sender's*
// localStorage, so a scan on the peer's device never had
// anything to resolve, and the records leaked the session's
// SDP, keys and SAS code onto disk forever. See the note
// where createQRReference used to be.
else if (parsedData.type === 'secure_offer_reference') {
setMessages(prev => [...prev, {
message: 'This QR code uses a retired format that could not transfer the invitation. Ask your peer to generate a new one, or use copy/paste.',
type: 'error'
}]);
return false;
} else {
// If payload was compressed, it's already decompressed above; keep legacy warning only when clearly incomplete
if (!parsedData.sdp && parsedData.type === 'enhanced_secure_offer') {
+555
View File
@@ -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;
}
}
+230 -59
View File
@@ -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) {
try {
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) {
try {
// Rate limiting is always available in this implementation
return { passed: true, details: 'Rate limiting is active and working' };
const limiter = EnhancedSecureCryptoUtils.rateLimiter;
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) {
return { passed: false, details: `Rate limiting test failed: ${error.message}` };
}
}
static async verifyMetadataProtection(securityManager) {
try {
// Metadata protection is always enabled in this implementation
return { passed: true, details: 'Metadata protection is working correctly' };
const metadataKey = securityManager?.metadataKey;
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) {
return { passed: false, details: `Metadata protection test failed: ${error.message}` };
}
}
static async verifyPerfectForwardSecrecy(securityManager) {
try {
// Perfect Forward Secrecy is always enabled in this implementation
return { passed: true, details: 'Perfect Forward Secrecy is configured and active' };
// Session-level PFS is real: every session runs a fresh ephemeral ECDH
// 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) {
return { passed: false, details: `PFS test failed: ${error.message}` };
}
}
static async verifyReplayProtection(securityManager) {
try {
// Debug logs removed to prevent leaking runtime state
@@ -757,16 +865,29 @@ class EnhancedSecureCryptoUtils {
static async verifyNonExtractableKeys(securityManager) {
try {
if (!securityManager.encryptionKey) return false;
// Test if keys are non-extractable
const keyData = await crypto.subtle.exportKey('raw', securityManager.encryptionKey);
return keyData && keyData.byteLength > 0;
} catch (error) {
// If export fails, keys are non-extractable (which is good)
return true;
// This check was inverted: it returned true when exportKey SUCCEEDED —
// 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.
const keys = [
['encryptionKey', securityManager?.encryptionKey],
['macKey', securityManager?.macKey],
['metadataKey', securityManager?.metadataKey]
];
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) {
@@ -1085,15 +1206,22 @@ class EnhancedSecureCryptoUtils {
namedCurve: 'P-384'
},
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
return keyPair;
} catch (p384Error) {
EnhancedSecureCryptoUtils.secureLog.log('warn', 'Elliptic curve P-384 generation failed, switching curve', { error: p384Error.message });
// Fallback to P-256
const keyPair = await crypto.subtle.generateKey(
{
@@ -1101,7 +1229,7 @@ class EnhancedSecureCryptoUtils {
namedCurve: 'P-256'
},
false, // Non-extractable for enhanced security
['deriveKey']
['deriveKey', 'deriveBits']
);
// Removed key generation info logging to avoid exposing key-related metadata
@@ -1879,49 +2007,62 @@ class EnhancedSecureCryptoUtils {
const saltBytes = new Uint8Array(salt);
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 sharedSecretBits = null;
try {
// Removed detailed key derivation logging
// Use pure ECDH to derive raw key material
const rawKeyMaterial = await crypto.subtle.deriveKey(
sharedSecretBits = await crypto.subtle.deriveBits(
{
name: 'ECDH',
public: publicKey
},
privateKey,
{
name: 'AES-GCM',
length: 256
},
true, // Extractable
['encrypt', 'decrypt']
256
);
// 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(
'raw',
rawKeyData,
sharedSecretBits,
{
name: 'HKDF',
hash: 'SHA-256'
},
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) {
EnhancedSecureCryptoUtils.secureLog.log('error', 'ECDH derivation failed', {
EnhancedSecureCryptoUtils.secureLog.log('error', 'ECDH derivation failed', {
error: error.message
});
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
// Removed detailed key derivation logging
@@ -2000,27 +2141,53 @@ class EnhancedSecureCryptoUtils {
['encrypt', 'decrypt']
);
// Generate temporary extractable key for fingerprint calculation
let fingerprintKey;
fingerprintKey = await crypto.subtle.deriveKey(
// Root key for the Double Ratchet, derived here rather than handing the
// raw ECDH secret to the caller: the secret is wiped before this
// 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',
hash: 'SHA-256',
salt: saltBytes,
info: encoder.encode('fingerprint-generation-v4')
info: encoder.encode('double-ratchet-root-v1')
},
rawSharedSecret,
{
name: 'AES-GCM',
length: 256
},
true, // Extractable only for fingerprint
['encrypt', 'decrypt']
256
);
const ratchetRoot = new Uint8Array(ratchetRootBits);
// Generate key fingerprint for verification
const fingerprintKeyData = await crypto.subtle.exportKey('raw', fingerprintKey);
const fingerprint = await EnhancedSecureCryptoUtils.generateKeyFingerprint(Array.from(new Uint8Array(fingerprintKeyData)));
// Fingerprint material. Previously this derived a second EXTRACTABLE
// AES key purely so it could be exported — leaving another copy of
// 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
if (!(messageKey instanceof CryptoKey)) {
@@ -2062,6 +2229,10 @@ class EnhancedSecureCryptoUtils {
macKey,
pfsKey, // Added Perfect Forward Secrecy key
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,
timestamp: Date.now(),
version: '4.0'
+58 -2
View File
@@ -19,6 +19,62 @@ function fromBase64Url(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
function generateUUID() {
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
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('🔓 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
const compressed = fromBase64Url(originalBody);
const decompressed = pako.inflate(compressed);
const decompressed = inflateBounded(compressed, 'fallback');
console.log('🔓 Decompressed length:', decompressed.length);
// Convert to ArrayBuffer for CBOR decoding
File diff suppressed because it is too large Load Diff
+15 -4
View File
@@ -46,7 +46,7 @@ class NotificationIntegration {
// IMPORTANT: forward ALL arguments (incl. per-message `meta`) so the app
// still receives view-once / disappearing / unsend metadata.
this.webrtcManager.onMessage = (message, type, ...rest) => {
this.handleIncomingMessage(message, type);
this.handleIncomingMessage(message, type, rest[0]);
// Call original callback if it exists
if (this.originalOnMessage) {
@@ -70,7 +70,7 @@ class NotificationIntegration {
if (this.webrtcManager.deliverMessageToUI) {
this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager);
this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => {
this.handleIncomingMessage(message, type);
this.handleIncomingMessage(message, type, rest[0]);
this.originalDeliverMessageToUI(message, type, ...rest);
};
}
@@ -89,7 +89,7 @@ class NotificationIntegration {
* @param {string} type - Message type
* @private
*/
handleIncomingMessage(message, type) {
handleIncomingMessage(message, type, meta) {
try {
// Create a unique key for this message to avoid duplicates
const messageKey = `${type}:${typeof message === 'string' ? message : JSON.stringify(message)}`;
@@ -121,10 +121,21 @@ class NotificationIntegration {
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
const notificationResult = this.notificationManager.notify(
messageInfo.senderName,
messageInfo.text,
notificationText,
{
icon: messageInfo.senderAvatar,
senderId: messageInfo.senderId,
+21
View File
@@ -20,8 +20,29 @@ window.EnhancedSecureWebRTCManager = EnhancedSecureWebRTCManager;
window.EnhancedSecureFileTransfer = EnhancedSecureFileTransfer;
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
const start = () => {
purgeLegacyOfferRecords();
if (typeof window.initializeApp === 'function') {
window.initializeApp();
} else if (window.DEBUG_MODE) {
+65 -2
View File
@@ -403,6 +403,19 @@ class EnhancedSecureFileTransfer {
this.incomingTransferChunkLimiters = new Map();
this.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE = 30000; // per transfer (~8 MB/s)
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
this.sessionKeys = new Map(); // fileId -> derived session key
@@ -538,7 +551,44 @@ class EnhancedSecureFileTransfer {
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) {
@@ -1331,13 +1381,25 @@ class EnhancedSecureFileTransfer {
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 = {
...metadata,
// Never carry the sender's claim forward — only our own verdict.
isVoice: validation.isVoice,
fileName: validation.displayName,
receivedAt: Date.now()
};
this.pendingIncomingTransfers.set(metadata.fileId, pendingMetadata);
if (validation.isVoice) {
this.autoAcceptedVoiceBytes += metadata.fileSize;
}
if (typeof this.onIncomingFileRequest === 'function') {
this.onIncomingFileRequest({
fileId: pendingMetadata.fileId,
@@ -1345,7 +1407,8 @@ class EnhancedSecureFileTransfer {
fileSize: pendingMetadata.fileSize,
mimeType: pendingMetadata.fileType || 'application/octet-stream',
// 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
});
} else {