Files
securebit-chat/tests/security-level-shape.test.mjs
T
lockbitchat 27279ae7c6
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
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.
2026-08-05 23:02:20 -04:00

121 lines
5.1 KiB
JavaScript

// The header renders `level` and `score` straight from whatever the security
// getter returns:
//
// sec.level || 'Secure' -> label
// sec.score + '%' -> score badge
//
// so any getter the header may call must carry both fields. A getter that
// returned only per-feature booleans rendered as "Secure undefined%".
// These tests pin the shape for every branch of the header's fallback chain.
import assert from 'node:assert/strict';
globalThis.window = globalThis.window || {};
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
const SCORED = {
level: 'HIGH',
score: 90,
color: 'green',
isRealData: true,
passedChecks: 9,
totalChecks: 10
};
function createManager(overrides = {}) {
return Object.assign({
ecdhKeyPair: {},
ecdsaKeyPair: {},
encryptionKey: {},
hmacKey: {},
replayProtectionEnabled: true,
// 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',
_peerDTLSFingerprint: 'cc:dd',
verificationCode: '1234567',
localVerificationConfirmed: true,
isRatchetActive: () => true,
connectionId: 'conn-1',
keyFingerprint: 'ff:ee',
_secureLog() {},
calculateAndReportSecurityLevel: async () => ({ ...SCORED }),
getRealSecurityLevel: P.getRealSecurityLevel
}, overrides);
}
// ── the scored fields survive, alongside the feature flags ───────────────────
{
const data = await createManager().getRealSecurityLevel();
assert.equal(typeof data.score, 'number', 'score must be numeric (renders as `score + "%"`)');
assert.equal(typeof data.level, 'string', 'level must be a string (renders as the label)');
assert.equal(data.score, 90);
assert.equal(data.level, 'HIGH');
assert.equal(data.isRealData, true);
assert.equal(data.passedChecks, 9);
assert.equal(data.totalChecks, 10);
// Feature flags are still exposed for the detailed security panel.
assert.equal(data.ecdhKeyExchange, true);
assert.equal(data.sasCode, true);
assert.equal(data.replayProtection, true);
// What the header would actually paint.
assert.equal(String(data.level || 'Secure'), 'HIGH');
assert.equal(data.score + '%', '90%');
}
// ── 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 ────────────
{
// calculateAndReportSecurityLevel returns null when the session is not yet
// connected/verified or the keys are missing.
const data = await createManager({
calculateAndReportSecurityLevel: async () => null
}).getRealSecurityLevel();
assert.equal(data.isRealData, false, 'UI keeps its previous value when isRealData is false');
assert.equal(typeof data.score, 'number');
assert.equal(typeof data.level, 'string');
assert.notEqual(data.score + '%', 'undefined%');
}
// ── the header's other fallback branch keeps the same contract ───────────────
{
const data = await createManager().calculateAndReportSecurityLevel();
assert.equal(typeof data.score, 'number');
assert.equal(typeof data.level, 'string');
}
console.log('Security level shape tests passed');