feat(handshake): shrink the connection exchange; release v5.9.0
The invitation carried the whole session: both public keys, their signatures, a salt, a challenge and the full SDP. It now carries only what brings up DTLS — ICE credentials, the certificate fingerprint, candidates, an expiry — plus a 16-byte commitment to the key material. The key material itself moves to the DataChannel and is checked against that commitment before it is parsed. Measured on the live site: 2274 characters across 4 animated QR frames became 151 characters in a single frame. The safety code is now computed over a transcript of both descriptors and both key blobs, and the HKDF salt is derived from that same transcript instead of being transmitted. authProof is replaced by one signature over it. SBQ2_SEND_ENABLED is the single value that reverts new invitations to SB1.
This commit is contained in:
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v5.9.0 — The invitation is now one small QR code
|
||||
|
||||
The connection descriptor moves to SBQ2 and the key material moves onto the
|
||||
DataChannel. Measured on the live site: the invitation payload went from **2274
|
||||
characters across 4 animated QR frames to 151 characters in a single frame**.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Invitations use the SBQ2 format.** The out-of-band code now carries only what
|
||||
brings up DTLS — ICE credentials, the certificate fingerprint, candidates, an
|
||||
expiry — plus a 16-byte commitment to the key material.
|
||||
|
||||
- **Key material is exchanged in band.** The ECDH and ECDSA public keys travel as
|
||||
the first frame on the DataChannel, and are checked against the commitment from
|
||||
the invitation *before* they are parsed or imported. A mismatch closes the
|
||||
connection; it is not a warning.
|
||||
|
||||
- **The SAS is computed over a transcript** covering both descriptors verbatim and
|
||||
both key blobs, with length prefixes. Anything an attacker can alter anywhere in
|
||||
the handshake, in either direction, changes the digits the two people compare.
|
||||
|
||||
- **The HKDF salt is derived from that transcript instead of transmitted**, so
|
||||
every session key is bound to both DTLS fingerprints and every candidate, and
|
||||
neither side can steer it.
|
||||
|
||||
- **`authProof` is replaced by one signature over the transcript.** The old
|
||||
challenge/response echoed a nonce back across seven fields; the signature proves
|
||||
the same possession and binds the whole handshake at once.
|
||||
|
||||
- The Double Ratchet starts from the transcript-derived material. SBQ2 postdates
|
||||
the ratchet entirely, so support is implied by the format rather than advertised
|
||||
in it — which also removes the silent "peer is old, use static keys" fallback
|
||||
from this path.
|
||||
|
||||
### Rollback
|
||||
|
||||
`EnhancedSecureWebRTCManager.SBQ2_SEND_ENABLED = false` and redeploy puts every
|
||||
new invitation back on SB1. Reception of both formats is unconditional and is not
|
||||
governed by the flag, so a client built with it off still reads SBQ2 invitations.
|
||||
|
||||
### Compatibility
|
||||
|
||||
SB1 invitations are still read, and the animated multi-frame QR path stays for
|
||||
them. A client older than 5.9.0 cannot read an SBQ2 invitation: from 5.9.0 on, an
|
||||
unrecognised `SB<n>:` family reports "This invitation was created by a newer
|
||||
version of SecureBit. Please update the app to connect." Versions 5.8.1 and
|
||||
earlier predate that check and show a JSON parse error instead — the two ends must
|
||||
both be on 5.9.0+.
|
||||
|
||||
|
||||
## v5.8.1 — SBQ2 rebuilds SDP that Firefox accepts
|
||||
|
||||
Still nothing in the application calls the SBQ2 descriptor; this fixes defects in
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
No accounts. No servers storing your messages. No installation required.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](#install-as-an-app)
|
||||
[](#security-model)
|
||||
[](#forward-secrecy)
|
||||
|
||||
Vendored
+1299
-17
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
+295
-2
@@ -36165,7 +36165,7 @@ async function packSecurePayload(payloadObj, senderEcdsaPrivKey = null, recipien
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = await crypto.subtle.encrypt(
|
||||
const enc2 = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
cek,
|
||||
new TextEncoder().encode(payloadJson)
|
||||
@@ -36173,7 +36173,7 @@ async function packSecurePayload(payloadObj, senderEcdsaPrivKey = null, recipien
|
||||
ciphertextCose = {
|
||||
protected: { alg: "A256GCM" },
|
||||
unprotected: { epk: ephemeralRaw },
|
||||
ciphertext: new Uint8Array(enc),
|
||||
ciphertext: new Uint8Array(enc2),
|
||||
iv
|
||||
};
|
||||
} else {
|
||||
@@ -36455,6 +36455,279 @@ async function assembleFromQrStrings(qrStrings) {
|
||||
window.packSecurePayload = packSecurePayload;
|
||||
window.receiveAndProcess = receiveAndProcess;
|
||||
|
||||
// src/network/descriptor/sbq2.js
|
||||
var SBQ2_VERSION = 2;
|
||||
var LIMITS = Object.freeze({
|
||||
MAX_PAYLOAD_BYTES: 512,
|
||||
// ~3.5x the largest descriptor we have ever measured
|
||||
MAX_CANDIDATES: 8,
|
||||
MIN_UFRAG: 4,
|
||||
// RFC 8839: ice-ufrag is 4..256 chars
|
||||
MAX_UFRAG: 64,
|
||||
MIN_PWD: 22,
|
||||
// RFC 8839: ice-pwd is 22..256 chars, >=128 bits of randomness
|
||||
MAX_PWD: 64,
|
||||
FINGERPRINT_BYTES: 32,
|
||||
// SHA-256
|
||||
COMMITMENT_BYTES: 16,
|
||||
// 128-bit second-preimage resistance
|
||||
BINDING_BYTES: 8,
|
||||
MAX_LIFETIME_MINUTES: 60,
|
||||
MAX_EXT_BYTES: 255,
|
||||
// Byte budget for candidates admitted BEYOND the coverage set (coverage
|
||||
// itself is never cut — see pruneCandidates). Derived from the acceptance
|
||||
// target rather than picked: the largest answer head we have measured is
|
||||
// Firefox's, at 104 bytes (version+flags+expiry+tag+fingerprint+8-char
|
||||
// ufrag+32-char pwd+count+commitment), and QR version 8 at level M holds
|
||||
// 152 bytes in byte mode. 152 - 104 = 48.
|
||||
SURPLUS_CANDIDATE_BYTES: 48,
|
||||
// Clock-skew allowance, applied in both directions on the expiry check.
|
||||
//
|
||||
// Two minutes is chosen against the failure it exists for: a receiver whose
|
||||
// clock is off. An NTP-synced device is within milliseconds, and an
|
||||
// unsynced modern device drifts on the order of seconds per day, so two
|
||||
// minutes swallows every ordinary case. It does NOT swallow a grossly wrong
|
||||
// clock (manually set, or reset to the epoch by a dead battery) — that is
|
||||
// deliberate, because such a device cannot be given a meaningful freshness
|
||||
// guarantee and should be told so. The cost is that the replay window grows
|
||||
// from the nominal 10 minutes to 12; keeping the tolerance well under the
|
||||
// lifetime is what bounds that.
|
||||
CLOCK_SKEW_MS: 12e4
|
||||
});
|
||||
var EPOCH_MS = Date.UTC(2024, 0, 1);
|
||||
var TYPE2 = Object.freeze({ OFFER: 0, ANSWER: 1 });
|
||||
var SETUP = Object.freeze(["actpass", "active", "passive"]);
|
||||
var MMS_ENUM = Object.freeze([262144, 1073741823, 65536, null]);
|
||||
var MMS_EXPLICIT = 3;
|
||||
var EXT = Object.freeze({ MAX_MESSAGE_SIZE: 1 });
|
||||
var KIND = Object.freeze({
|
||||
HOST_V4: 0,
|
||||
HOST_MDNS: 1,
|
||||
SRFLX_V4: 2,
|
||||
RELAY_V4: 3,
|
||||
HOST_V6: 4,
|
||||
SRFLX_V6: 5,
|
||||
RELAY_V6: 6
|
||||
});
|
||||
var KIND_ADDR_LEN = Object.freeze({ 0: 4, 1: 16, 2: 4, 3: 4, 4: 16, 5: 16, 6: 16 });
|
||||
var KIND_TYPE = Object.freeze({ 0: "host", 1: "host", 2: "srflx", 3: "relay", 4: "host", 5: "srflx", 6: "relay" });
|
||||
var KIND_FAMILY = Object.freeze({ 0: "v4", 1: "mdns", 2: "v4", 3: "v4", 4: "v6", 5: "v6", 6: "v6" });
|
||||
var TCPTYPE = Object.freeze([null, "passive", "active", "so"]);
|
||||
var TYPE_PREF = Object.freeze({ host: 126, srflx: 100, relay: 0 });
|
||||
var ICE_CHAR = /^[A-Za-z0-9+/]+$/;
|
||||
var DescriptorError = class extends Error {
|
||||
constructor(message, code = "malformed") {
|
||||
super(message);
|
||||
this.name = "DescriptorError";
|
||||
this.code = code;
|
||||
}
|
||||
};
|
||||
var fail = (msg, code) => {
|
||||
throw new DescriptorError(msg, code);
|
||||
};
|
||||
var Reader = class {
|
||||
constructor(buf) {
|
||||
this.buf = buf;
|
||||
this.i = 0;
|
||||
}
|
||||
need(n) {
|
||||
if (this.i + n > this.buf.length) fail("descriptor is truncated");
|
||||
}
|
||||
u8() {
|
||||
this.need(1);
|
||||
return this.buf[this.i++];
|
||||
}
|
||||
u16() {
|
||||
this.need(2);
|
||||
const v = this.buf[this.i] << 8 | this.buf[this.i + 1];
|
||||
this.i += 2;
|
||||
return v;
|
||||
}
|
||||
u24() {
|
||||
this.need(3);
|
||||
const v = this.buf[this.i] << 16 | this.buf[this.i + 1] << 8 | this.buf[this.i + 2];
|
||||
this.i += 3;
|
||||
return v;
|
||||
}
|
||||
u32() {
|
||||
this.need(4);
|
||||
const v = (this.buf[this.i] << 24 >>> 0) + (this.buf[this.i + 1] << 16) + (this.buf[this.i + 2] << 8) + this.buf[this.i + 3];
|
||||
this.i += 4;
|
||||
return v >>> 0;
|
||||
}
|
||||
bytes(n) {
|
||||
this.need(n);
|
||||
return this.buf.slice(this.i, this.i += n);
|
||||
}
|
||||
ascii(n) {
|
||||
this.need(n);
|
||||
let s = "";
|
||||
for (let k = 0; k < n; k++) {
|
||||
const c = this.buf[this.i + k];
|
||||
if (c < 32 || c > 126) fail("non-printable byte in a text field");
|
||||
s += String.fromCharCode(c);
|
||||
}
|
||||
this.i += n;
|
||||
return s;
|
||||
}
|
||||
get rest() {
|
||||
return this.buf.length - this.i;
|
||||
}
|
||||
};
|
||||
function decodeExt(buf) {
|
||||
const r = new Reader(buf);
|
||||
const out = /* @__PURE__ */ new Map();
|
||||
let lastType = -1;
|
||||
while (r.rest > 0) {
|
||||
const type = r.u8();
|
||||
const len = r.u8();
|
||||
const value = r.bytes(len);
|
||||
if (type <= lastType) fail("extension records must be in ascending type order without duplicates");
|
||||
lastType = type;
|
||||
switch (type) {
|
||||
case EXT.MAX_MESSAGE_SIZE: {
|
||||
if (len !== 4) fail("extension 0x01 must be 4 bytes");
|
||||
const v = new Reader(value).u32();
|
||||
if (v < 1024 || v > 2147483647) fail("extension 0x01 value is out of range");
|
||||
if (MMS_ENUM.includes(v)) fail("extension 0x01 duplicates a value the flags already encode");
|
||||
out.set(type, v);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
fail(`unknown extension type 0x${type.toString(16).padStart(2, "0")}`, "unknown_extension");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function decodeDescriptor(buf, { nowMs = Date.now() } = {}) {
|
||||
if (!(buf instanceof Uint8Array)) fail("descriptor must be a Uint8Array");
|
||||
if (buf.length === 0) fail("descriptor is empty");
|
||||
if (buf.length > LIMITS.MAX_PAYLOAD_BYTES) fail("descriptor exceeds the payload limit");
|
||||
const r = new Reader(buf);
|
||||
const version = r.u8();
|
||||
if (version !== SBQ2_VERSION) fail(`unsupported descriptor version 0x${version.toString(16)}`, "version");
|
||||
const flags = r.u8();
|
||||
const type = flags & 3;
|
||||
if (type !== TYPE2.OFFER && type !== TYPE2.ANSWER) fail("reserved descriptor type");
|
||||
const setup = flags >> 2 & 3;
|
||||
if (setup > 2) fail("reserved DTLS setup role");
|
||||
const mmsIndex = flags >> 4 & 3;
|
||||
const hasCommitment = (flags & 64) !== 0;
|
||||
const hasExt = (flags & 128) !== 0;
|
||||
const minutes = r.u24();
|
||||
const expiresAtMs = EPOCH_MS + minutes * 6e4;
|
||||
if (nowMs - LIMITS.CLOCK_SKEW_MS > expiresAtMs) {
|
||||
const lateMin = Math.round((nowMs - expiresAtMs) / 6e4);
|
||||
fail(
|
||||
`this code expired ${lateMin} minute(s) ago. If it was just created, this device's clock or time zone is probably wrong \u2014 check the date and time settings.`,
|
||||
"expired"
|
||||
);
|
||||
}
|
||||
if (expiresAtMs - nowMs > LIMITS.MAX_LIFETIME_MINUTES * 6e4 + LIMITS.CLOCK_SKEW_MS) {
|
||||
fail("descriptor lifetime is implausibly long", "lifetime");
|
||||
}
|
||||
const bindingTag = type === TYPE2.ANSWER ? r.bytes(LIMITS.BINDING_BYTES) : null;
|
||||
const fingerprint = r.bytes(LIMITS.FINGERPRINT_BYTES);
|
||||
const ufragLen = r.u8();
|
||||
if (ufragLen < LIMITS.MIN_UFRAG || ufragLen > LIMITS.MAX_UFRAG) fail("ice-ufrag length out of range");
|
||||
const ufrag = r.ascii(ufragLen);
|
||||
if (!ICE_CHAR.test(ufrag)) fail("ice-ufrag contains characters outside the ICE alphabet");
|
||||
const pwdLen = r.u8();
|
||||
if (pwdLen < LIMITS.MIN_PWD || pwdLen > LIMITS.MAX_PWD) fail("ice-pwd length out of range");
|
||||
const pwd = r.ascii(pwdLen);
|
||||
if (!ICE_CHAR.test(pwd)) fail("ice-pwd contains characters outside the ICE alphabet");
|
||||
const count = r.u8();
|
||||
if (count > LIMITS.MAX_CANDIDATES) fail("too many candidates");
|
||||
const candidates = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tagByte = r.u8();
|
||||
const kind = tagByte >> 4 & 15;
|
||||
const tcptype = tagByte & 15;
|
||||
const addrLen = KIND_ADDR_LEN[kind];
|
||||
if (addrLen === void 0) fail(`reserved candidate kind ${kind}`);
|
||||
if (tcptype >= TCPTYPE.length) fail("reserved TCP candidate type");
|
||||
const addr = r.bytes(addrLen);
|
||||
const port = r.u16();
|
||||
if (port < 1) fail("candidate port must be non-zero");
|
||||
candidates.push({ kind, tcptype, addr, port });
|
||||
}
|
||||
let commitment = null;
|
||||
if (hasCommitment) commitment = r.bytes(LIMITS.COMMITMENT_BYTES);
|
||||
let extensions = /* @__PURE__ */ new Map();
|
||||
if (hasExt) {
|
||||
const extLen = r.u8();
|
||||
if (extLen === 0) fail("extension area is flagged but empty");
|
||||
extensions = decodeExt(r.bytes(extLen));
|
||||
}
|
||||
if (r.rest !== 0) fail(`${r.rest} trailing byte(s) after the descriptor`);
|
||||
let maxMessageSize;
|
||||
if (mmsIndex === MMS_EXPLICIT) {
|
||||
if (!extensions.has(EXT.MAX_MESSAGE_SIZE)) fail("flags promise an explicit max-message-size but no extension carries it");
|
||||
maxMessageSize = extensions.get(EXT.MAX_MESSAGE_SIZE);
|
||||
} else {
|
||||
if (extensions.has(EXT.MAX_MESSAGE_SIZE)) fail("extension 0x01 present but the flags do not select it");
|
||||
maxMessageSize = MMS_ENUM[mmsIndex];
|
||||
}
|
||||
return {
|
||||
version,
|
||||
type,
|
||||
setup,
|
||||
maxMessageSize,
|
||||
expiresAtMs,
|
||||
bindingTag,
|
||||
fingerprint,
|
||||
ufrag,
|
||||
pwd,
|
||||
candidates,
|
||||
commitment,
|
||||
extensions
|
||||
};
|
||||
}
|
||||
var enc = new TextEncoder();
|
||||
var B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
function toBase64Url2(bytes) {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
|
||||
out += B64URL[a >> 2];
|
||||
out += B64URL[(a & 3) << 4 | (b ?? 0) >> 4];
|
||||
if (b === void 0) break;
|
||||
out += B64URL[(b & 15) << 2 | (c ?? 0) >> 6];
|
||||
if (c === void 0) break;
|
||||
out += B64URL[c & 63];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function fromBase64Url2(text) {
|
||||
if (typeof text !== "string") fail("payload must be a string");
|
||||
const s = text.replace(/\s+/g, "");
|
||||
if (s.length > Math.ceil(LIMITS.MAX_PAYLOAD_BYTES * 4 / 3) + 4) fail("payload is too long");
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(s)) fail("payload contains characters outside base64url");
|
||||
if (s.length % 4 === 1) fail("payload has an impossible length");
|
||||
const out = new Uint8Array(Math.floor(s.length * 3 / 4));
|
||||
let o = 0, acc = 0, bits = 0;
|
||||
for (const ch of s) {
|
||||
acc = acc << 6 | B64URL.indexOf(ch);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[o++] = acc >> bits & 255;
|
||||
}
|
||||
}
|
||||
if (acc & (1 << bits) - 1) fail("payload has non-zero padding bits");
|
||||
return out.subarray(0, o);
|
||||
}
|
||||
var TEXT_PREFIX = "SB2:";
|
||||
function encodeText(bytes) {
|
||||
return TEXT_PREFIX + toBase64Url2(bytes);
|
||||
}
|
||||
function decodeText(text) {
|
||||
if (typeof text !== "string") fail("payload must be a string");
|
||||
const t = text.trim();
|
||||
if (!t.startsWith(TEXT_PREFIX)) fail("not an SB2 descriptor");
|
||||
return fromBase64Url2(t.slice(TEXT_PREFIX.length));
|
||||
}
|
||||
|
||||
// src/scripts/qr-local.js
|
||||
var COMPRESSION_PREFIX = "SB1:gz:";
|
||||
var BINARY_PREFIX = "SB1:bin:";
|
||||
@@ -36579,6 +36852,7 @@ window.compressToPrefixedGzip = function(text) {
|
||||
window.encodeBinaryToPrefixed = function(objOrJson) {
|
||||
try {
|
||||
const obj = typeof objOrJson === "string" ? JSON.parse(objOrJson) : objOrJson;
|
||||
if (obj && typeof obj.sbq2 === "string") return obj.sbq2;
|
||||
const b64url = encodeObjectToBinaryBase64Url(obj);
|
||||
return BINARY_PREFIX + b64url;
|
||||
} catch (e) {
|
||||
@@ -36589,6 +36863,22 @@ window.encodeBinaryToPrefixed = function(objOrJson) {
|
||||
window.decodeAnyPayload = function(scannedText) {
|
||||
try {
|
||||
if (typeof scannedText === "string") {
|
||||
if (scannedText.startsWith(TEXT_PREFIX)) {
|
||||
const bytes = decodeText(scannedText);
|
||||
const desc = decodeDescriptor(bytes);
|
||||
return { t: desc.type === TYPE2.OFFER ? "offer" : "answer", sbq2: scannedText };
|
||||
}
|
||||
if (scannedText.charCodeAt(0) === 2) {
|
||||
const bytes = Uint8Array.from(scannedText, (c) => c.charCodeAt(0) & 255);
|
||||
const desc = decodeDescriptor(bytes);
|
||||
return { t: desc.type === TYPE2.OFFER ? "offer" : "answer", sbq2: encodeText(bytes) };
|
||||
}
|
||||
const family = /^SB(\d+):/.exec(scannedText);
|
||||
if (family && family[1] !== "1" && family[1] !== "2") {
|
||||
throw new Error(
|
||||
"This invitation was created by a newer version of SecureBit. Please update the app to connect."
|
||||
);
|
||||
}
|
||||
if (scannedText.startsWith(BINARY_PREFIX)) {
|
||||
const b64url = scannedText.slice(BINARY_PREFIX.length);
|
||||
return decodeBinaryBase64UrlToObject(b64url);
|
||||
@@ -36600,6 +36890,9 @@ window.decodeAnyPayload = function(scannedText) {
|
||||
return scannedText;
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof scannedText === "string" && (scannedText.startsWith(TEXT_PREFIX) || scannedText.charCodeAt(0) === 2 || /^SB(\d+):/.test(scannedText))) {
|
||||
throw e;
|
||||
}
|
||||
console.warn("decodeAnyPayload failed:", e?.message || e);
|
||||
}
|
||||
return scannedText;
|
||||
|
||||
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,7 +7,7 @@ this document describes.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Release | v5.8.1 |
|
||||
| Release | v5.9.0 |
|
||||
| Protocol version | 4.1 |
|
||||
| Ratchet wire version | 1 |
|
||||
|
||||
@@ -214,5 +214,5 @@ worse than one that reports nothing.
|
||||
|
||||
## Scope
|
||||
|
||||
This describes the browser implementation as it stands in v5.8.1. It is not a
|
||||
This describes the browser implementation as it stands in v5.9.0. It is not a
|
||||
substitute for independent cryptographic review.
|
||||
|
||||
+22
-22
@@ -24,7 +24,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="./manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786051485967">
|
||||
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786054741114">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<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">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786051485967">
|
||||
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786054741114">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
|
||||
@@ -99,7 +99,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786051485967">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786054741114">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -183,7 +183,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="config/ice-servers.js?v=1786051485967"></script>
|
||||
<script defer src="config/ice-servers.js?v=1786054741114"></script>
|
||||
<script defer src="libs/react/react.production.min.js"></script>
|
||||
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -191,8 +191,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="assets/tailwind.css?v=1786051485967">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786051485967">
|
||||
<link rel="stylesheet" href="assets/tailwind.css?v=1786054741114">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786054741114">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -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-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="src/styles/main.css?v=1786051485967">
|
||||
<link rel="stylesheet" href="src/styles/animations.css?v=1786051485967">
|
||||
<link rel="stylesheet" href="src/styles/components.css?v=1786051485967">
|
||||
<link rel="stylesheet" href="src/styles/main.css?v=1786054741114">
|
||||
<link rel="stylesheet" href="src/styles/animations.css?v=1786054741114">
|
||||
<link rel="stylesheet" href="src/styles/components.css?v=1786054741114">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="src/scripts/load-async-css.js?v=1786051485967"></script>
|
||||
<script defer src="src/scripts/load-async-css.js?v=1786054741114"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="src/scripts/fa-check.js?v=1786051485967"></script>
|
||||
<script defer src="src/scripts/fa-check.js?v=1786054741114"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="src/utils/updateManager.js?v=1786051485967"></script>
|
||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1786051485967"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1786051485967"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1786051485967"></script>
|
||||
<script defer src="src/utils/updateManager.js?v=1786054741114"></script>
|
||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1786054741114"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1786054741114"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1786054741114"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="dist/app-boot.js?v=1786051485967"></script>
|
||||
<script type="module" src="dist/app.js?v=1786051485967"></script>
|
||||
<script type="module" src="dist/app-boot.js?v=1786054741114"></script>
|
||||
<script type="module" src="dist/app.js?v=1786054741114"></script>
|
||||
|
||||
<script defer src="src/scripts/pwa-register.js?v=1786051485967"></script>
|
||||
<script src="./src/pwa/install-prompt.js?v=1786051485967" type="module"></script>
|
||||
<script src="./src/pwa/pwa-manager.js?v=1786051485967" type="module"></script>
|
||||
<script defer src="./src/scripts/pwa-offline-test.js?v=1786051485967"></script>
|
||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786051485967">
|
||||
<script defer src="src/scripts/pwa-register.js?v=1786054741114"></script>
|
||||
<script src="./src/pwa/install-prompt.js?v=1786054741114" type="module"></script>
|
||||
<script src="./src/pwa/pwa-manager.js?v=1786054741114" type="module"></script>
|
||||
<script defer src="./src/scripts/pwa-offline-test.js?v=1786054741114"></script>
|
||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786054741114">
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1786051485967",
|
||||
"buildVersion": "1786051485967",
|
||||
"appVersion": "5.8.1",
|
||||
"buildTime": "2026-08-06T21:24:46.003Z",
|
||||
"buildId": "1786051485967-6e82cfc",
|
||||
"gitHash": "6e82cfc",
|
||||
"version": "1786054741114",
|
||||
"buildVersion": "1786054741114",
|
||||
"appVersion": "5.9.0",
|
||||
"buildTime": "2026-08-06T22:19:01.153Z",
|
||||
"buildId": "1786054741114-fb959d7",
|
||||
"gitHash": "fb959d7",
|
||||
"generated": true,
|
||||
"generatedAt": "2026-08-06T21:24:46.005Z"
|
||||
"generatedAt": "2026-08-06T22:19:01.154Z"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "securebit-chat",
|
||||
"version": "5.8.1",
|
||||
"version": "5.9.0",
|
||||
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": "npm run build && python -m http.server 8000",
|
||||
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
||||
"serve": "npx http-server -p 8000",
|
||||
"test": "node tests/sas-verification.test.mjs && node tests/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/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.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/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"p2p",
|
||||
|
||||
@@ -12,6 +12,32 @@ import { NetworkAdaptationController } from './webrtc/adaptation/controller.js';
|
||||
// session's static keys were not enough on their own.
|
||||
import { DoubleRatchet } from '../crypto/DoubleRatchet.js';
|
||||
|
||||
// SBQ2: the compact out-of-band descriptor and the in-band key exchange that
|
||||
// makes it possible. See doc/DESCRIPTOR-SBQ2.md.
|
||||
import {
|
||||
parseSdp as sbq2ParseSdp,
|
||||
pruneCandidates as sbq2PruneCandidates,
|
||||
encodeDescriptor as sbq2Encode,
|
||||
decodeDescriptor as sbq2Decode,
|
||||
serializeSdp as sbq2SerializeSdp,
|
||||
bindingTag as sbq2BindingTag,
|
||||
commitBlob as sbq2CommitBlob,
|
||||
encodeText as sbq2EncodeText,
|
||||
decodeText as sbq2DecodeText,
|
||||
TYPE as SBQ2_TYPE,
|
||||
TEXT_PREFIX as SBQ2_TEXT_PREFIX,
|
||||
} from './descriptor/sbq2.js';
|
||||
import {
|
||||
ROLE as KX_ROLE,
|
||||
encodeKeyBlob,
|
||||
decodeKeyBlob,
|
||||
buildTranscript,
|
||||
deriveTranscriptSalt,
|
||||
proofPayload,
|
||||
computeTranscriptSas,
|
||||
verifyBlobCommitment,
|
||||
} from './descriptor/keyexchange.js';
|
||||
|
||||
// MUTEX SYSTEM FIXES - RESOLVING MESSAGE DELIVERY ISSUES
|
||||
// ============================================
|
||||
// Issue: After introducing the Mutex system, messages stopped being delivered between users
|
||||
@@ -197,6 +223,13 @@ class EnhancedSecureWebRTCManager {
|
||||
// it asks the offerer to drive the restart.
|
||||
ICE_RESTART_REQUEST: 'ice_restart_request',
|
||||
|
||||
// SBQ2 in-band key exchange. These are the only two frames that legitimately
|
||||
// arrive before any key exists, which is exactly why they are handled in one
|
||||
// place and nowhere else: KEY_BLOB carries the key material the descriptor's
|
||||
// commitment covers, KEY_PROOF the signature over the transcript.
|
||||
KEY_BLOB: 'key_blob',
|
||||
KEY_PROOF: 'key_proof',
|
||||
|
||||
// Fake traffic
|
||||
FAKE: 'fake'
|
||||
};
|
||||
@@ -225,6 +258,23 @@ class EnhancedSecureWebRTCManager {
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST
|
||||
]);
|
||||
|
||||
// ── SBQ2 ROLLBACK SWITCH ────────────────────────────────────────────────
|
||||
// Flip this ONE value to false and redeploy to put every new invitation back
|
||||
// on the SB1 format. Nothing else needs touching: reception of both formats
|
||||
// is unconditional, so a client built with this off still reads SBQ2
|
||||
// invitations from a client built with it on.
|
||||
//
|
||||
// It only governs what we EMIT. It is deliberately not consulted anywhere in
|
||||
// the receive path, and never inside an established session — see
|
||||
// _handshakeMode, which is latched per connection so a session cannot be
|
||||
// pushed back onto the weaker format halfway through.
|
||||
static SBQ2_SEND_ENABLED = true;
|
||||
|
||||
// How long the in-band key exchange may take from channel open to verified
|
||||
// proof. Generous next to the ~1 s a handshake actually needs, tight enough
|
||||
// that a peer that never sends its blob does not hang the UI indefinitely.
|
||||
static SBQ2_KEY_EXCHANGE_TIMEOUT_MS = 15000;
|
||||
|
||||
static PROTOCOL_VERSION = '4.1';
|
||||
// Double Ratchet wire version. Bump only on an incompatible ratchet change;
|
||||
// peers compare it and fall back to static keys when it is absent or unknown.
|
||||
@@ -416,6 +466,14 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
this.maxSequenceGap = 100; // Maximum allowed sequence gap
|
||||
this.replayProtectionEnabled = true; // Enable/disable replay protection
|
||||
this.sessionId = null; // MITM protection: Session identifier
|
||||
|
||||
// Handshake format latched for THIS connection: 'sb1' or 'sbq2'. Set when the
|
||||
// first descriptor of the session is produced or parsed, and never changed
|
||||
// afterwards. That latch is what makes downgrade impossible inside a session:
|
||||
// an SBQ2 session that hits any trouble fails, it does not retry as SB1.
|
||||
this._handshakeMode = null;
|
||||
// Per-connection SBQ2 state. Null on the SB1 path.
|
||||
this._sbq2 = null;
|
||||
this.connectionId = Array.from(crypto.getRandomValues(new Uint8Array(8)))
|
||||
.map(b => b.toString(16).padStart(2, '0')).join(''); // Connection identifier for AAD
|
||||
this.peerPublicKey = null; // Store peer's public key for PFS
|
||||
@@ -4519,6 +4577,369 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SBQ2 — compact descriptor + in-band key exchange
|
||||
// ========================================================================
|
||||
|
||||
/** True once this connection has latched onto the SBQ2 handshake. */
|
||||
_isSbq2() { return this._handshakeMode === 'sbq2'; }
|
||||
|
||||
/**
|
||||
* Latch the handshake format for this connection.
|
||||
*
|
||||
* Called with the format of the first descriptor of the session. Calling it
|
||||
* again with a different value is a bug or an attack, and is refused: this
|
||||
* is the single point that makes "no downgrade inside a session" true rather
|
||||
* than merely intended.
|
||||
*/
|
||||
_latchHandshakeMode(mode) {
|
||||
if (this._handshakeMode && this._handshakeMode !== mode) {
|
||||
throw new Error(
|
||||
`handshake format cannot change mid-session (${this._handshakeMode} -> ${mode})`
|
||||
);
|
||||
}
|
||||
this._handshakeMode = mode;
|
||||
}
|
||||
|
||||
_sbq2State() {
|
||||
if (!this._sbq2) {
|
||||
this._sbq2 = {
|
||||
role: null, // KX_ROLE.OFFER | KX_ROLE.ANSWER
|
||||
localDescriptor: null, // Uint8Array, our descriptor verbatim
|
||||
remoteDescriptor: null, // Uint8Array, peer's descriptor verbatim
|
||||
localBlob: null,
|
||||
remoteBlob: null,
|
||||
remoteCommitment: null, // from the peer's descriptor
|
||||
transcript: null,
|
||||
peerEcdhKey: null,
|
||||
peerEcdsaKey: null,
|
||||
blobSent: false,
|
||||
proofSent: false,
|
||||
proofVerified: false,
|
||||
keysDerived: false,
|
||||
pendingProof: null, // proof that arrived before the transcript existed
|
||||
completed: false,
|
||||
timer: null,
|
||||
startedAt: 0,
|
||||
};
|
||||
}
|
||||
return this._sbq2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed.
|
||||
*
|
||||
* Every SBQ2 failure lands here: there is no path that logs a warning and
|
||||
* carries on with a weaker session, and no path that retries as SB1. The
|
||||
* connection is torn down and the user is told, because a handshake that
|
||||
* went wrong is exactly the case where continuing is worst.
|
||||
*/
|
||||
_sbq2Abort(code, userMessage) {
|
||||
const st = this._sbq2;
|
||||
if (st?.timer) { clearTimeout(st.timer); st.timer = null; }
|
||||
this._secureLog('error', 'SBQ2 handshake aborted', { code });
|
||||
try { this.deliverMessageToUI(userMessage, 'system'); } catch (_) {}
|
||||
try { this.onStatusChange?.('failed'); } catch (_) {}
|
||||
try { this.disconnect(); } catch (_) {}
|
||||
}
|
||||
|
||||
/** SPKI bytes for a public CryptoKey. */
|
||||
async _exportSpki(key) {
|
||||
return new Uint8Array(await crypto.subtle.exportKey('spki', key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the key blob for this side and the commitment that goes in the
|
||||
* descriptor. Called while creating our descriptor, so the commitment is
|
||||
* fixed before anything is shown to the user.
|
||||
*/
|
||||
async _sbq2BuildLocalBlob(role) {
|
||||
if (!this.ecdhKeyPair?.publicKey || !this.ecdsaKeyPair?.publicKey) {
|
||||
throw new Error('SBQ2: key pairs are not ready');
|
||||
}
|
||||
const blob = encodeKeyBlob({
|
||||
role,
|
||||
ecdhSpki: await this._exportSpki(this.ecdhKeyPair.publicKey),
|
||||
ecdsaSpki: await this._exportSpki(this.ecdsaKeyPair.publicKey),
|
||||
});
|
||||
const digest = async (b) => new Uint8Array(await crypto.subtle.digest('SHA-256', b));
|
||||
const commitment = await sbq2CommitBlob(digest, blob);
|
||||
const st = this._sbq2State();
|
||||
st.role = role;
|
||||
st.localBlob = blob;
|
||||
return { blob, commitment };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn our gathered localDescription into a compact descriptor.
|
||||
* @returns {{bytes: Uint8Array, text: string}}
|
||||
*/
|
||||
async _sbq2BuildDescriptor(type, { bindingTag = null, lifetimeMs = 10 * 60 * 1000 } = {}) {
|
||||
const sdp = this.peerConnection?.localDescription?.sdp;
|
||||
if (!sdp) throw new Error('SBQ2: no local description to encode');
|
||||
|
||||
const role = type === SBQ2_TYPE.OFFER ? KX_ROLE.OFFER : KX_ROLE.ANSWER;
|
||||
const { commitment } = await this._sbq2BuildLocalBlob(role);
|
||||
|
||||
const raw = sbq2ParseSdp(sdp);
|
||||
const bytes = sbq2Encode({
|
||||
type,
|
||||
expiresAtMs: Date.now() + lifetimeMs,
|
||||
sdpFields: { ...raw, candidates: sbq2PruneCandidates(raw.candidates) },
|
||||
commitment,
|
||||
...(type === SBQ2_TYPE.ANSWER ? { bindingTag } : {}),
|
||||
});
|
||||
|
||||
const st = this._sbq2State();
|
||||
st.localDescriptor = bytes;
|
||||
|
||||
this._secureLog('info', 'SBQ2 descriptor built', {
|
||||
type: type === SBQ2_TYPE.OFFER ? 'offer' : 'answer',
|
||||
bytes: bytes.length,
|
||||
candidates: sbq2PruneCandidates(raw.candidates).length,
|
||||
});
|
||||
|
||||
return { bytes, text: sbq2EncodeText(bytes) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and adopt a peer descriptor. Throws on anything the strict decoder
|
||||
* refuses — version, reserved values, unknown TLV, trailing bytes, expiry.
|
||||
*/
|
||||
_sbq2AdoptRemoteDescriptor(bytes, expectedType) {
|
||||
const desc = sbq2Decode(bytes);
|
||||
if (desc.type !== expectedType) {
|
||||
throw new Error(
|
||||
`expected an ${expectedType === SBQ2_TYPE.OFFER ? 'invitation' : 'answer'}, got the other kind`
|
||||
);
|
||||
}
|
||||
if (!desc.commitment) {
|
||||
// Without a commitment there is nothing binding the in-band key
|
||||
// material to the code the user scanned, which is the entire basis
|
||||
// for moving that material in band. Refuse rather than proceed on
|
||||
// the DTLS fingerprint alone.
|
||||
throw new Error('the invitation carries no key commitment');
|
||||
}
|
||||
const st = this._sbq2State();
|
||||
st.remoteDescriptor = bytes;
|
||||
st.remoteCommitment = desc.commitment;
|
||||
return desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the in-band key exchange. Called once, as soon as the DataChannel
|
||||
* opens, before anything else is allowed to use the channel.
|
||||
*/
|
||||
async _runSbq2KeyExchange() {
|
||||
const st = this._sbq2State();
|
||||
if (st.completed || st.blobSent) return;
|
||||
st.startedAt = Date.now();
|
||||
|
||||
if (!st.localBlob || !st.localDescriptor || !st.remoteDescriptor || !st.remoteCommitment) {
|
||||
this._sbq2Abort('incomplete_state',
|
||||
'The secure handshake could not start because the connection setup is incomplete. Please start a new invitation.');
|
||||
return;
|
||||
}
|
||||
|
||||
st.timer = setTimeout(() => {
|
||||
if (!st.completed) {
|
||||
this._sbq2Abort('timeout',
|
||||
'The other side did not complete the secure handshake in time. Please try connecting again.');
|
||||
}
|
||||
}, EnhancedSecureWebRTCManager.SBQ2_KEY_EXCHANGE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
this.dataChannel.send(JSON.stringify({
|
||||
type: EnhancedSecureWebRTCManager.MESSAGE_TYPES.KEY_BLOB,
|
||||
v: 2,
|
||||
blob: window.EnhancedSecureCryptoUtils.arrayBufferToBase64(st.localBlob.buffer.slice(
|
||||
st.localBlob.byteOffset, st.localBlob.byteOffset + st.localBlob.byteLength)),
|
||||
}));
|
||||
st.blobSent = true;
|
||||
this._secureLog('info', 'SBQ2 key blob sent', { bytes: st.localBlob.length });
|
||||
} catch (error) {
|
||||
this._sbq2Abort('blob_send_failed',
|
||||
'The secure handshake could not be sent. Please try connecting again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the two in-band handshake frames. These are the only frames
|
||||
* accepted before keys exist, so everything they touch is validated here and
|
||||
* nothing is acted on before the commitment check passes.
|
||||
*/
|
||||
async _sbq2HandleHandshakeFrame(parsed) {
|
||||
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
|
||||
const st = this._sbq2State();
|
||||
|
||||
if (!this._isSbq2()) {
|
||||
// An SB1 session must never accept these: it has no commitment to
|
||||
// check them against, which would make them unauthenticated key
|
||||
// material.
|
||||
this._secureLog('error', 'Rejected SBQ2 handshake frame on a non-SBQ2 session', {
|
||||
messageType: parsed?.type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.type === T.KEY_BLOB) {
|
||||
if (st.remoteBlob) {
|
||||
// A second blob would mean either a confused peer or an
|
||||
// attempt to replace key material after it was accepted.
|
||||
this._sbq2Abort('duplicate_blob',
|
||||
'The secure handshake was sent twice. The connection has been closed for safety.');
|
||||
return;
|
||||
}
|
||||
const bytes = new Uint8Array(window.EnhancedSecureCryptoUtils.base64ToArrayBuffer(String(parsed.blob || '')));
|
||||
|
||||
// COMMITMENT FIRST. Nothing below this line may run against
|
||||
// unverified bytes: not decodeKeyBlob, not importKey.
|
||||
await verifyBlobCommitment(crypto.subtle, bytes, st.remoteCommitment);
|
||||
|
||||
const blob = decodeKeyBlob(bytes);
|
||||
const expectedRole = st.role === KX_ROLE.OFFER ? KX_ROLE.ANSWER : KX_ROLE.OFFER;
|
||||
if (blob.role !== expectedRole) {
|
||||
this._sbq2Abort('role_mismatch',
|
||||
'The other side sent the wrong kind of handshake. The connection has been closed for safety.');
|
||||
return;
|
||||
}
|
||||
|
||||
st.remoteBlob = bytes;
|
||||
st.peerEcdhKey = await crypto.subtle.importKey(
|
||||
'spki', blob.ecdhSpki, { name: 'ECDH', namedCurve: 'P-384' }, false, []);
|
||||
st.peerEcdsaKey = await crypto.subtle.importKey(
|
||||
'spki', blob.ecdsaSpki, { name: 'ECDSA', namedCurve: 'P-384' }, false, ['verify']);
|
||||
|
||||
await this._sbq2CompleteExchange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === T.KEY_PROOF) {
|
||||
const sig = new Uint8Array(window.EnhancedSecureCryptoUtils.base64ToArrayBuffer(String(parsed.sig || '')));
|
||||
if (!st.transcript || !st.peerEcdsaKey) {
|
||||
// The proof can legitimately overtake our own processing;
|
||||
// hold it until the transcript exists, then verify.
|
||||
st.pendingProof = sig;
|
||||
return;
|
||||
}
|
||||
await this._sbq2VerifyProof(sig);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = error?.code || 'handshake_failed';
|
||||
const message = code === 'commitment_mismatch'
|
||||
? 'The key material does not match the invitation you scanned. This can mean someone tampered with the connection, so it has been closed.'
|
||||
: 'The secure handshake failed. The connection has been closed for safety.';
|
||||
this._sbq2Abort(code, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Both blobs are in hand and verified: define the transcript, derive
|
||||
* everything from it, and prove possession of the identity key.
|
||||
*/
|
||||
async _sbq2CompleteExchange() {
|
||||
const st = this._sbq2State();
|
||||
if (st.keysDerived || !st.remoteBlob || !st.localBlob) return;
|
||||
|
||||
// Ratchet capability is IMPLIED by the format, not advertised in it.
|
||||
//
|
||||
// The SB1 descriptor carries `dr` because a 5.6.x peer might not know
|
||||
// about the ratchet, and there is no server to roll both ends at once.
|
||||
// SBQ2 has no such ambiguity: it postdates RATCHET_VERSION 1 entirely, so
|
||||
// anything that can produce this handshake can ratchet. Saying so here
|
||||
// costs zero descriptor bytes and — more importantly — stops
|
||||
// _initializeRatchet from taking its silent "peer is old, fall back to
|
||||
// static keys" path, which is a downgrade nothing in an SBQ2 session
|
||||
// should ever reach.
|
||||
this._peerSupportsRatchet = true;
|
||||
|
||||
const isOffer = st.role === KX_ROLE.OFFER;
|
||||
st.transcript = buildTranscript({
|
||||
offerDescriptor: isOffer ? st.localDescriptor : st.remoteDescriptor,
|
||||
answerDescriptor: isOffer ? st.remoteDescriptor : st.localDescriptor,
|
||||
offerBlob: isOffer ? st.localBlob : st.remoteBlob,
|
||||
answerBlob: isOffer ? st.remoteBlob : st.localBlob,
|
||||
});
|
||||
|
||||
// The salt is derived, never sent. That binds every session key to both
|
||||
// DTLS fingerprints and every candidate in both descriptors.
|
||||
this.sessionSalt = await deriveTranscriptSalt(crypto.subtle, st.transcript);
|
||||
|
||||
this.peerPublicKey = st.peerEcdhKey;
|
||||
this.peerECDHPublicKey = st.peerEcdhKey;
|
||||
|
||||
const derivedKeys = await window.EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||
this.ecdhKeyPair.privateKey, st.peerEcdhKey, this.sessionSalt);
|
||||
|
||||
await this._setEncryptionKeys(
|
||||
derivedKeys.messageKey, derivedKeys.macKey, derivedKeys.metadataKey, derivedKeys.fingerprint);
|
||||
|
||||
await this._initializeRatchet(derivedKeys, /* isInitiator */ isOffer);
|
||||
st.keysDerived = true;
|
||||
|
||||
// Identity proof: one signature over the whole transcript, replacing the
|
||||
// old challenge/response. Sent after our keys exist so a failure here
|
||||
// cannot leave us half-configured.
|
||||
const sig = new Uint8Array(await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-384' }, this.ecdsaKeyPair.privateKey, proofPayload(st.transcript)));
|
||||
this.dataChannel.send(JSON.stringify({
|
||||
type: EnhancedSecureWebRTCManager.MESSAGE_TYPES.KEY_PROOF,
|
||||
sig: window.EnhancedSecureCryptoUtils.arrayBufferToBase64(sig.buffer),
|
||||
}));
|
||||
st.proofSent = true;
|
||||
|
||||
if (st.pendingProof) {
|
||||
const held = st.pendingProof;
|
||||
st.pendingProof = null;
|
||||
await this._sbq2VerifyProof(held);
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify the peer's transcript signature, then release the session. */
|
||||
async _sbq2VerifyProof(sig) {
|
||||
const st = this._sbq2State();
|
||||
if (st.proofVerified) return;
|
||||
|
||||
const ok = await crypto.subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-384' }, st.peerEcdsaKey, sig, proofPayload(st.transcript));
|
||||
if (!ok) {
|
||||
this._sbq2Abort('bad_proof',
|
||||
'The other side could not prove it owns its identity key. The connection has been closed for safety.');
|
||||
return;
|
||||
}
|
||||
st.proofVerified = true;
|
||||
|
||||
// SAS over the transcript. Everything that travelled out of band, in
|
||||
// both directions, plus both blobs, is inside these digits.
|
||||
this.verificationCode = await computeTranscriptSas(crypto.subtle, {
|
||||
ecdhPrivateKey: this.ecdhKeyPair.privateKey,
|
||||
peerEcdhPublicKey: st.peerEcdhKey,
|
||||
transcript: st.transcript,
|
||||
});
|
||||
|
||||
const localFP = this.expectedDTLSFingerprint;
|
||||
const remoteFP = this._peerDTLSFingerprint;
|
||||
if (localFP && remoteFP) this._setSASMaterialReady(localFP, remoteFP);
|
||||
|
||||
st.completed = true;
|
||||
if (st.timer) { clearTimeout(st.timer); st.timer = null; }
|
||||
|
||||
this.securityFeatures.hasMutualAuth = true;
|
||||
this.securityFeatures.hasMetadataProtection = true;
|
||||
this.securityFeatures.hasEnhancedReplayProtection = true;
|
||||
|
||||
this._secureLog('info', 'SBQ2 in-band key exchange complete', {
|
||||
elapsedMs: Date.now() - st.startedAt,
|
||||
ratchetActive: this.isRatchetActive?.() === true,
|
||||
});
|
||||
|
||||
try { this.onKeyExchange?.(this.keyFingerprint); } catch (_) {}
|
||||
this._notifyVerificationReadyIfPossible();
|
||||
this.initiateVerification();
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITY: Decode hex keyFingerprint to Uint8Array for SAS computation
|
||||
* @param {string} hexString - Hex encoded keyFingerprint (e.g., "aa:bb:cc:dd")
|
||||
@@ -8376,6 +8797,28 @@ async processMessage(data) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// SBQ2: the key material has not moved yet. Run the in-band exchange
|
||||
// before anything else touches the channel — establishConnection and
|
||||
// the file-transfer subsystem both assume keys exist, and the SAS
|
||||
// cannot be computed until the transcript is closed.
|
||||
//
|
||||
// Read the latched field rather than calling _isSbq2(): this handler
|
||||
// is shared by both handshake paths and by the recovery tests, and a
|
||||
// helper call here would make an unrelated path depend on a method
|
||||
// that has nothing to do with opening a channel.
|
||||
if (this._handshakeMode === 'sbq2') {
|
||||
try {
|
||||
await this._runSbq2KeyExchange();
|
||||
} catch (error) {
|
||||
this._secureLog('error', 'SBQ2 key exchange failed to start', {
|
||||
errorType: error?.constructor?.name || 'Unknown'
|
||||
});
|
||||
this._sbq2Abort('start_failed',
|
||||
'The secure handshake could not be started. Please try connecting again.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.establishConnection();
|
||||
|
||||
@@ -8620,6 +9063,20 @@ async processMessage(data) {
|
||||
// SYSTEM MESSAGES (WITHOUT MUTEX)
|
||||
// ============================================
|
||||
|
||||
// ============================================
|
||||
// SBQ2 IN-BAND KEY EXCHANGE
|
||||
// ============================================
|
||||
// The only frames that legitimately precede any key. They
|
||||
// are dispatched before the system-message branch so they
|
||||
// can never be confused with post-handshake traffic, and
|
||||
// _sbq2HandleHandshakeFrame refuses them outright on a
|
||||
// session that is not SBQ2.
|
||||
if (parsed.type === EnhancedSecureWebRTCManager.MESSAGE_TYPES.KEY_BLOB ||
|
||||
parsed.type === EnhancedSecureWebRTCManager.MESSAGE_TYPES.KEY_PROOF) {
|
||||
await this._sbq2HandleHandshakeFrame(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type && ['heartbeat', 'verification', 'verification_response', 'verification_confirmed', 'verification_both_confirmed', 'sas_code', 'peer_disconnect', 'security_upgrade'].includes(parsed.type)) {
|
||||
this.handleSystemMessage(parsed);
|
||||
return;
|
||||
@@ -10868,6 +11325,21 @@ async processMessage(data) {
|
||||
}
|
||||
}));
|
||||
|
||||
// ============================================
|
||||
// PHASE 15: SBQ2 — EMIT THE COMPACT DESCRIPTOR INSTEAD
|
||||
// ============================================
|
||||
// Everything above still runs: the key pairs it generates are the
|
||||
// ones the in-band exchange will publish, and the DTLS
|
||||
// fingerprint it extracted is the anchor the descriptor carries.
|
||||
// What changes is what leaves the device — a descriptor with a
|
||||
// commitment, not the key material itself.
|
||||
if (EnhancedSecureWebRTCManager.SBQ2_SEND_ENABLED) {
|
||||
this._latchHandshakeMode('sbq2');
|
||||
const { text } = await this._sbq2BuildDescriptor(SBQ2_TYPE.OFFER);
|
||||
return { t: 'offer', sbq2: text };
|
||||
}
|
||||
|
||||
this._latchHandshakeMode('sb1');
|
||||
return offerPackage;
|
||||
|
||||
} catch (error) {
|
||||
@@ -10996,7 +11468,89 @@ async processMessage(data) {
|
||||
* FULLY FIXED METHOD createSecureAnswer()
|
||||
* With race-condition protection and enhanced security
|
||||
*/
|
||||
/**
|
||||
* SBQ2 answer path.
|
||||
*
|
||||
* Deliberately separate from createSecureAnswer rather than folded into it:
|
||||
* that method's job is to import the peer's keys and derive the session from
|
||||
* the offer, and here there are no peer keys yet — only a fingerprint and a
|
||||
* commitment. Sharing the body would mean threading "do we have keys yet?"
|
||||
* through fifteen phases, and the whole point of the format is that the
|
||||
* answer is produced before any key material has been seen.
|
||||
*/
|
||||
async _createSbq2Answer(offerData) {
|
||||
return this._withMutex('connectionOperation', async (operationId) => {
|
||||
try {
|
||||
this._resetNotificationFlags();
|
||||
if (!this._checkRateLimit()) {
|
||||
throw new Error('Connection rate limit exceeded. Please wait before trying again.');
|
||||
}
|
||||
this._latchHandshakeMode('sbq2');
|
||||
|
||||
// Strict decode of untrusted input, before anything is used.
|
||||
const offerBytes = sbq2DecodeText(String(offerData.sbq2));
|
||||
const desc = this._sbq2AdoptRemoteDescriptor(offerBytes, SBQ2_TYPE.OFFER);
|
||||
const { sdp: remoteSdp } = sbq2SerializeSdp(desc);
|
||||
|
||||
this.isInitiator = false;
|
||||
this.onStatusChange('connecting');
|
||||
|
||||
const keyPairs = await this._generateEncryptionKeys();
|
||||
this.ecdhKeyPair = keyPairs.ecdhKeyPair;
|
||||
this.ecdsaKeyPair = keyPairs.ecdsaKeyPair;
|
||||
if (!this.ecdhKeyPair?.privateKey || !this.ecdsaKeyPair?.privateKey) {
|
||||
throw new Error('Failed to generate valid key pairs');
|
||||
}
|
||||
|
||||
this.createPeerConnection();
|
||||
|
||||
// The peer's DTLS fingerprint comes straight from the descriptor,
|
||||
// which is the value the user carried by hand. This is the anchor
|
||||
// the whole in-band exchange hangs from.
|
||||
this._peerDTLSFingerprint = Array.from(desc.fingerprint,
|
||||
(b) => b.toString(16).padStart(2, '0').toUpperCase()).join(':');
|
||||
|
||||
await this.peerConnection.setRemoteDescription({ type: 'offer', sdp: remoteSdp });
|
||||
await this.peerConnection.setLocalDescription(await this.peerConnection.createAnswer({
|
||||
offerToReceiveAudio: false,
|
||||
offerToReceiveVideo: false
|
||||
}));
|
||||
|
||||
this.expectedDTLSFingerprint =
|
||||
this._extractDTLSFingerprintFromSDP(this.peerConnection.localDescription.sdp);
|
||||
|
||||
// Manual exchange has no trickle channel, so the descriptor must
|
||||
// carry a complete candidate set.
|
||||
await this.waitForIceGathering();
|
||||
|
||||
const digest = async (b) => new Uint8Array(await crypto.subtle.digest('SHA-256', b));
|
||||
const { text } = await this._sbq2BuildDescriptor(SBQ2_TYPE.ANSWER, {
|
||||
bindingTag: await sbq2BindingTag(digest, offerBytes),
|
||||
});
|
||||
|
||||
document.dispatchEvent(new CustomEvent('new-connection', {
|
||||
detail: { type: 'answer', timestamp: Date.now(), operationId }
|
||||
}));
|
||||
|
||||
return { t: 'answer', sbq2: text };
|
||||
} catch (error) {
|
||||
this._secureLog('error', 'SBQ2 answer creation failed', {
|
||||
operationId,
|
||||
errorType: error?.constructor?.name || 'Unknown'
|
||||
});
|
||||
this.onStatusChange('disconnected');
|
||||
throw error;
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
async createSecureAnswer(offerData) {
|
||||
// Format detection happens before the mutex and before any validation
|
||||
// written for the old shape: an SBQ2 invitation has none of the fields
|
||||
// the SB1 validator requires, so it must never reach it.
|
||||
if (offerData && typeof offerData.sbq2 === 'string') {
|
||||
return this._createSbq2Answer(offerData);
|
||||
}
|
||||
return this._withMutex('connectionOperation', async (operationId) => {
|
||||
this._secureLog('info', 'Creating secure answer with mutex', {
|
||||
operationId: operationId,
|
||||
@@ -11864,7 +12418,54 @@ async processMessage(data) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SBQ2 answer handling on the offerer side.
|
||||
*
|
||||
* Sets the remote description and nothing else. No key is imported and no
|
||||
* secret derived here, because none has been sent yet — that happens in
|
||||
* _runSbq2KeyExchange once the channel is open and the commitment has been
|
||||
* checked.
|
||||
*/
|
||||
async _handleSbq2Answer(answerData) {
|
||||
if (!this._isSbq2()) {
|
||||
// We advertised SB1 and got SBQ2 back. Refuse rather than switch:
|
||||
// an established session's format is latched, and this is precisely
|
||||
// the shape a downgrade/upgrade confusion attack would take.
|
||||
throw new Error('Received a new-format response to an old-format invitation. Please start a new invitation.');
|
||||
}
|
||||
const st = this._sbq2State();
|
||||
const answerBytes = sbq2DecodeText(String(answerData.sbq2));
|
||||
const desc = sbq2Decode(answerBytes);
|
||||
if (desc.type !== SBQ2_TYPE.ANSWER) throw new Error('That code is an invitation, not a response to one.');
|
||||
if (!desc.commitment) throw new Error('The response carries no key commitment');
|
||||
|
||||
// One-shot binding: this must be the answer to the invitation we are
|
||||
// currently showing, not to any other one.
|
||||
const digest = async (b) => new Uint8Array(await crypto.subtle.digest('SHA-256', b));
|
||||
const expected = await sbq2BindingTag(digest, st.localDescriptor);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < expected.length; i++) diff |= expected[i] ^ desc.bindingTag[i];
|
||||
if (diff !== 0) {
|
||||
throw new Error('This response belongs to a different invitation. Ask for a response to the code you are showing now.');
|
||||
}
|
||||
|
||||
st.remoteDescriptor = answerBytes;
|
||||
st.remoteCommitment = desc.commitment;
|
||||
this._peerDTLSFingerprint = Array.from(desc.fingerprint,
|
||||
(b) => b.toString(16).padStart(2, '0').toUpperCase()).join(':');
|
||||
|
||||
const { sdp } = sbq2SerializeSdp(desc);
|
||||
await this.peerConnection.setRemoteDescription({ type: 'answer', sdp });
|
||||
|
||||
this._secureLog('info', 'SBQ2 answer accepted; awaiting in-band key exchange', {
|
||||
bytes: answerBytes.length
|
||||
});
|
||||
}
|
||||
|
||||
async handleSecureAnswer(answerData) {
|
||||
if (answerData && typeof answerData.sbq2 === 'string') {
|
||||
return this._handleSbq2Answer(answerData);
|
||||
}
|
||||
try {
|
||||
|
||||
if (!answerData || typeof answerData !== 'object' || Array.isArray(answerData)) {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// In-band key exchange for SBQ2.
|
||||
//
|
||||
// The out-of-band descriptor carries only what brings up DTLS, plus a 16-byte
|
||||
// commitment. The key material itself — ECDH key, ECDSA identity key — travels
|
||||
// as the FIRST frame on the DataChannel, and is checked against that commitment
|
||||
// before a single byte of it is used for anything.
|
||||
//
|
||||
// Order of operations, and why it is this order:
|
||||
//
|
||||
// 1. Both sides send their key blob as soon as the channel opens.
|
||||
// 2. Each verifies commitment(peer blob) against the commitment that arrived
|
||||
// in the peer's descriptor, over the channel the user authenticated by
|
||||
// looking at it. A mismatch tears the connection down. This happens BEFORE
|
||||
// the blob is parsed into keys, so a substituted blob never reaches
|
||||
// importKey.
|
||||
// 3. Only now is the transcript defined: both descriptors verbatim and both
|
||||
// blobs, length-prefixed. The HKDF salt is SHA-512 of it, so the session
|
||||
// keys are bound to both DTLS fingerprints and every candidate — the salt
|
||||
// is never transmitted and cannot be influenced independently by either
|
||||
// side.
|
||||
// 4. Each signs the transcript with its ECDSA key and sends the signature.
|
||||
// This replaces the old challenge/response authProof: one signature over
|
||||
// everything, instead of seven fields echoing a nonce back.
|
||||
// 5. The SAS is HKDF over the ECDH shared secret salted with the transcript,
|
||||
// so any change anywhere in the handshake — either descriptor, either blob
|
||||
// — changes the digits the two people read to each other.
|
||||
//
|
||||
// This module is pure: it takes SubtleCrypto in, touches no DOM and no network.
|
||||
|
||||
import { sasTranscript, commitBlob, LIMITS } from './sbq2.js';
|
||||
|
||||
export const KEY_BLOB_VERSION = 0x02;
|
||||
|
||||
export const ROLE = Object.freeze({ OFFER: 0, ANSWER: 1 });
|
||||
|
||||
export const BLOB_LIMITS = Object.freeze({
|
||||
// A P-384 SPKI is 120 bytes; P-521 would be 158. The ceiling is generous
|
||||
// enough for a future curve and far below anything that could be used to
|
||||
// wedge the parser.
|
||||
MAX_SPKI: 256,
|
||||
MIN_SPKI: 40,
|
||||
// ECDSA P-384 signatures are 96 bytes raw; P-521 is 132.
|
||||
MAX_SIG: 160,
|
||||
MIN_SIG: 48,
|
||||
MAX_BLOB_BYTES: 1024,
|
||||
});
|
||||
|
||||
class KeyExchangeError extends Error {
|
||||
constructor(message, code = 'key_exchange') {
|
||||
super(message);
|
||||
this.name = 'KeyExchangeError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
const fail = (msg, code) => { throw new KeyExchangeError(msg, code); };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// key blob
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* version u8 | role u8 | ecdhLen u16 | ecdh | ecdsaLen u16 | ecdsa
|
||||
*
|
||||
* No signature lives in here: the blob is what the commitment and the
|
||||
* transcript cover, and a signature over the transcript cannot be inside the
|
||||
* thing it signs. It travels separately, in the proof frame.
|
||||
*/
|
||||
export function encodeKeyBlob({ role, ecdhSpki, ecdsaSpki }) {
|
||||
if (role !== ROLE.OFFER && role !== ROLE.ANSWER) fail('invalid role');
|
||||
for (const [name, v] of [['ecdh', ecdhSpki], ['ecdsa', ecdsaSpki]]) {
|
||||
if (!(v instanceof Uint8Array)) fail(`${name} SPKI must be a Uint8Array`);
|
||||
if (v.length < BLOB_LIMITS.MIN_SPKI || v.length > BLOB_LIMITS.MAX_SPKI) {
|
||||
fail(`${name} SPKI length out of range`);
|
||||
}
|
||||
}
|
||||
const out = new Uint8Array(1 + 1 + 2 + ecdhSpki.length + 2 + ecdsaSpki.length);
|
||||
const dv = new DataView(out.buffer);
|
||||
let o = 0;
|
||||
out[o++] = KEY_BLOB_VERSION;
|
||||
out[o++] = role;
|
||||
dv.setUint16(o, ecdhSpki.length); o += 2;
|
||||
out.set(ecdhSpki, o); o += ecdhSpki.length;
|
||||
dv.setUint16(o, ecdsaSpki.length); o += 2;
|
||||
out.set(ecdsaSpki, o);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strict parser. Anything unexpected throws; there is no partial result. */
|
||||
export function decodeKeyBlob(buf) {
|
||||
if (!(buf instanceof Uint8Array)) fail('key blob must be a Uint8Array');
|
||||
if (buf.length === 0) fail('key blob is empty');
|
||||
if (buf.length > BLOB_LIMITS.MAX_BLOB_BYTES) fail('key blob exceeds the size limit');
|
||||
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
let o = 0;
|
||||
const need = (n) => { if (o + n > buf.length) fail('key blob is truncated'); };
|
||||
|
||||
need(1);
|
||||
const version = buf[o++];
|
||||
// Same rule as the descriptor: a version mismatch is an error, never an
|
||||
// attempt to parse a different shape.
|
||||
if (version !== KEY_BLOB_VERSION) fail(`unsupported key blob version 0x${version.toString(16)}`, 'version');
|
||||
|
||||
need(1);
|
||||
const role = buf[o++];
|
||||
if (role !== ROLE.OFFER && role !== ROLE.ANSWER) fail('reserved key blob role');
|
||||
|
||||
need(2);
|
||||
const ecdhLen = dv.getUint16(o); o += 2;
|
||||
if (ecdhLen < BLOB_LIMITS.MIN_SPKI || ecdhLen > BLOB_LIMITS.MAX_SPKI) fail('ECDH SPKI length out of range');
|
||||
need(ecdhLen);
|
||||
const ecdhSpki = buf.slice(o, o + ecdhLen); o += ecdhLen;
|
||||
|
||||
need(2);
|
||||
const ecdsaLen = dv.getUint16(o); o += 2;
|
||||
if (ecdsaLen < BLOB_LIMITS.MIN_SPKI || ecdsaLen > BLOB_LIMITS.MAX_SPKI) fail('ECDSA SPKI length out of range');
|
||||
need(ecdsaLen);
|
||||
const ecdsaSpki = buf.slice(o, o + ecdsaLen); o += ecdsaLen;
|
||||
|
||||
// Trailing bytes are malformed input, not padding to ignore — the same rule
|
||||
// the descriptor decoder applies, and for the same reason: what the
|
||||
// commitment covers must have exactly one reading.
|
||||
if (o !== buf.length) fail(`${buf.length - o} trailing byte(s) after the key blob`);
|
||||
|
||||
return { version, role, ecdhSpki, ecdsaSpki };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// transcript
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Canonical transcript. Argument order is by ROLE, never by who is calling, so
|
||||
* both peers hash identical bytes.
|
||||
*/
|
||||
export function buildTranscript({ offerDescriptor, answerDescriptor, offerBlob, answerBlob }) {
|
||||
for (const [name, v] of Object.entries({ offerDescriptor, answerDescriptor, offerBlob, answerBlob })) {
|
||||
if (!(v instanceof Uint8Array) || v.length === 0) fail(`transcript component ${name} is missing`);
|
||||
}
|
||||
return sasTranscript(offerDescriptor, answerDescriptor, offerBlob, answerBlob);
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF salt, derived rather than transmitted.
|
||||
*
|
||||
* deriveSharedKeys requires exactly 64 bytes, which SHA-512 supplies directly.
|
||||
* Deriving it here means the salt is bound to both DTLS fingerprints and every
|
||||
* candidate, and neither side can steer it: it is a hash of material the other
|
||||
* side already committed to.
|
||||
*/
|
||||
export async function deriveTranscriptSalt(subtle, transcript) {
|
||||
const h = await subtle.digest('SHA-512', transcript);
|
||||
return Array.from(new Uint8Array(h));
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
/** Bytes an ECDSA identity key signs to prove possession and bind the transcript. */
|
||||
export function proofPayload(transcript) {
|
||||
const label = enc.encode('sbq2/proof/v1\0');
|
||||
const out = new Uint8Array(label.length + transcript.length);
|
||||
out.set(label, 0);
|
||||
out.set(transcript, label.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* SAS digits.
|
||||
*
|
||||
* IKM is the raw ECDH shared secret, so an observer who has the whole
|
||||
* transcript still cannot predict the digits. The salt is the transcript hash,
|
||||
* so nothing in the handshake can move without moving the digits.
|
||||
*/
|
||||
export async function computeTranscriptSas(subtle, { ecdhPrivateKey, peerEcdhPublicKey, transcript, digits = 7 }) {
|
||||
const shared = await subtle.deriveBits({ name: 'ECDH', public: peerEcdhPublicKey }, ecdhPrivateKey, 256);
|
||||
let ikm = null;
|
||||
try {
|
||||
ikm = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveBits']);
|
||||
const salt = new Uint8Array(await subtle.digest('SHA-256', transcript));
|
||||
const bits = await subtle.deriveBits(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt, info: enc.encode('sbq2-sas-v1') },
|
||||
ikm, 64,
|
||||
);
|
||||
const dv = new DataView(bits);
|
||||
const n = (dv.getUint32(0) ^ dv.getUint32(4)) >>> 0;
|
||||
const mod = 10 ** digits;
|
||||
return String(n % mod).padStart(digits, '0');
|
||||
} finally {
|
||||
// The shared secret must not linger in the heap once the digits exist.
|
||||
try { new Uint8Array(shared).fill(0); } catch (_) { /* not a view we own */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a peer blob against the commitment that arrived out of band.
|
||||
*
|
||||
* Constant-time comparison is not required — the commitment is public — but the
|
||||
* check must happen before the blob is interpreted, which is why this takes raw
|
||||
* bytes and not a parsed structure.
|
||||
*/
|
||||
export async function verifyBlobCommitment(subtle, blobBytes, expectedCommitment) {
|
||||
if (!(expectedCommitment instanceof Uint8Array) || expectedCommitment.length !== LIMITS.COMMITMENT_BYTES) {
|
||||
fail('descriptor carried no usable commitment', 'commitment_missing');
|
||||
}
|
||||
const digest = async (b) => new Uint8Array(await subtle.digest('SHA-256', b));
|
||||
const actual = await commitBlob(digest, blobBytes);
|
||||
if (actual.length !== expectedCommitment.length) fail('commitment length mismatch', 'commitment_mismatch');
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expectedCommitment[i];
|
||||
if (diff !== 0) {
|
||||
fail('the key material does not match the commitment in the invitation', 'commitment_mismatch');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export { KeyExchangeError };
|
||||
@@ -10,6 +10,13 @@ import { Html5Qrcode } from 'html5-qrcode';
|
||||
import { gzip, ungzip, deflate, inflate } from 'pako';
|
||||
import * as cbor from 'cbor-js';
|
||||
import { packSecurePayload, receiveAndProcess } from '../crypto/cose-qr.js';
|
||||
import {
|
||||
decodeDescriptor as sbq2Decode,
|
||||
decodeText as sbq2DecodeText,
|
||||
encodeText as sbq2EncodeText,
|
||||
TYPE as SBQ2_TYPE,
|
||||
TEXT_PREFIX as SBQ2_TEXT_PREFIX,
|
||||
} from '../network/descriptor/sbq2.js';
|
||||
|
||||
// Compact payload prefix to signal gzip+base64 content
|
||||
const COMPRESSION_PREFIX = 'SB1:gz:';
|
||||
@@ -162,6 +169,10 @@ window.compressToPrefixedGzip = function (text) {
|
||||
window.encodeBinaryToPrefixed = function (objOrJson) {
|
||||
try {
|
||||
const obj = typeof objOrJson === 'string' ? JSON.parse(objOrJson) : objOrJson;
|
||||
// An SBQ2 descriptor is already its own compact wire form. Re-encoding it as
|
||||
// CBOR+deflate+base64 would wrap 124 bytes back up into ~200 characters of
|
||||
// SB1 envelope and undo the entire point.
|
||||
if (obj && typeof obj.sbq2 === 'string') return obj.sbq2;
|
||||
const b64url = encodeObjectToBinaryBase64Url(obj);
|
||||
return BINARY_PREFIX + b64url;
|
||||
} catch (e) {
|
||||
@@ -173,6 +184,37 @@ window.encodeBinaryToPrefixed = function (objOrJson) {
|
||||
window.decodeAnyPayload = function (scannedText) {
|
||||
try {
|
||||
if (typeof scannedText === 'string') {
|
||||
// SBQ2 first. The two families are distinguishable without guessing: an
|
||||
// SBQ2 payload is `SB2:` + base64url, an SB1 one is `SB1:bin:`/`SB1:gz:`,
|
||||
// and a raw-byte SBQ2 descriptor starts with 0x02 where any SB1 text
|
||||
// starts with ASCII 'S' (0x53).
|
||||
if (scannedText.startsWith(SBQ2_TEXT_PREFIX)) {
|
||||
const bytes = sbq2DecodeText(scannedText);
|
||||
const desc = sbq2Decode(bytes);
|
||||
// Hand back the shape the app already routes on, with the compact form
|
||||
// attached for the manager to re-parse. The manager decodes it again
|
||||
// from the text rather than trusting anything decided here — this layer
|
||||
// only classifies.
|
||||
return { t: desc.type === SBQ2_TYPE.OFFER ? 'offer' : 'answer', sbq2: scannedText };
|
||||
}
|
||||
if (scannedText.charCodeAt(0) === 0x02) {
|
||||
const bytes = Uint8Array.from(scannedText, (c) => c.charCodeAt(0) & 0xff);
|
||||
const desc = sbq2Decode(bytes);
|
||||
return { t: desc.type === SBQ2_TYPE.OFFER ? 'offer' : 'answer', sbq2: sbq2EncodeText(bytes) };
|
||||
}
|
||||
// A payload from a FUTURE format generation. Recognising the family
|
||||
// without being able to read it is the one case where we can say
|
||||
// something useful instead of letting JSON.parse produce
|
||||
// "Unexpected token 'S'". Every descriptor family is `SB<n>:`, so this
|
||||
// stays true for SB3 and beyond without another release.
|
||||
const family = /^SB(\d+):/.exec(scannedText);
|
||||
if (family && family[1] !== '1' && family[1] !== '2') {
|
||||
throw new Error(
|
||||
'This invitation was created by a newer version of SecureBit. ' +
|
||||
'Please update the app to connect.'
|
||||
);
|
||||
}
|
||||
|
||||
if (scannedText.startsWith(BINARY_PREFIX)) {
|
||||
const b64url = scannedText.slice(BINARY_PREFIX.length);
|
||||
return decodeBinaryBase64UrlToObject(b64url); // returns object
|
||||
@@ -185,6 +227,16 @@ window.decodeAnyPayload = function (scannedText) {
|
||||
return scannedText;
|
||||
}
|
||||
} catch (e) {
|
||||
// A payload that announced itself as SBQ2 and then failed to decode must not
|
||||
// fall through to the SB1 parser, which would report a generic "invalid
|
||||
// format" for what is really a strict-decoder rejection (bad version,
|
||||
// unknown extension, trailing bytes, expired). Surface it.
|
||||
if (typeof scannedText === 'string' &&
|
||||
(scannedText.startsWith(SBQ2_TEXT_PREFIX) ||
|
||||
scannedText.charCodeAt(0) === 0x02 ||
|
||||
/^SB(\d+):/.test(scannedText))) {
|
||||
throw e;
|
||||
}
|
||||
console.warn('decodeAnyPayload failed:', e?.message || e);
|
||||
}
|
||||
return scannedText;
|
||||
|
||||
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
|
||||
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
|
||||
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
|
||||
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
|
||||
const SW_BUILD_VERSION = '1786051485967';
|
||||
const SW_BUILD_VERSION = '1786054741114';
|
||||
|
||||
// Load version from meta.json on install
|
||||
async function getAppVersion() {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
// SBQ2 in-band key exchange: the commitment gate, transcript coverage, and the
|
||||
// identity proof that replaced authProof.
|
||||
//
|
||||
// The property under test throughout is that the descriptor the user carried by
|
||||
// hand is what pins the key material: substituting the blob must fail before the
|
||||
// blob is parsed, and anything that changes anywhere in the handshake must move
|
||||
// the SAS digits the two people read to each other.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { webcrypto as crypto } from 'node:crypto';
|
||||
|
||||
const {
|
||||
parseSdp, pruneCandidates, encodeDescriptor, decodeDescriptor,
|
||||
bindingTag, commitBlob, TYPE, LIMITS,
|
||||
} = await import('../src/network/descriptor/sbq2.js');
|
||||
const {
|
||||
ROLE, KEY_BLOB_VERSION, BLOB_LIMITS,
|
||||
encodeKeyBlob, decodeKeyBlob, buildTranscript, deriveTranscriptSalt,
|
||||
proofPayload, computeTranscriptSas, verifyBlobCommitment, KeyExchangeError,
|
||||
} = await import('../src/network/descriptor/keyexchange.js');
|
||||
|
||||
const subtle = crypto.subtle;
|
||||
const chrome = JSON.parse(readFileSync(new URL('./fixtures/sdp-chrome.json', import.meta.url)));
|
||||
const digest = async (b) => new Uint8Array(await subtle.digest('SHA-256', b));
|
||||
|
||||
const rejects = (fn, match, label) => assert.throws(fn, (e) => {
|
||||
assert.ok(e instanceof KeyExchangeError, `${label}: wrong error type ${e.name}`);
|
||||
assert.match(e.message, match, `${label}: unexpected message "${e.message}"`);
|
||||
return true;
|
||||
}, label);
|
||||
|
||||
const rejectsAsync = async (fn, match, label) => {
|
||||
await assert.rejects(fn, (e) => {
|
||||
assert.ok(e instanceof KeyExchangeError, `${label}: wrong error type ${e.name}`);
|
||||
assert.match(e.message, match, `${label}: unexpected message "${e.message}"`);
|
||||
return true;
|
||||
}, label);
|
||||
};
|
||||
|
||||
async function makePeer(role) {
|
||||
const ecdh = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, ['deriveKey', 'deriveBits']);
|
||||
const ecdsa = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']);
|
||||
const blob = encodeKeyBlob({
|
||||
role,
|
||||
ecdhSpki: new Uint8Array(await subtle.exportKey('spki', ecdh.publicKey)),
|
||||
ecdsaSpki: new Uint8Array(await subtle.exportKey('spki', ecdsa.publicKey)),
|
||||
});
|
||||
return { ecdh, ecdsa, blob, commitment: await commitBlob(digest, blob) };
|
||||
}
|
||||
|
||||
async function makeDescriptor(sdp, type, commitment, tag) {
|
||||
const raw = parseSdp(sdp);
|
||||
return encodeDescriptor({
|
||||
type, expiresAtMs: Date.now() + 600000,
|
||||
sdpFields: { ...raw, candidates: pruneCandidates(raw.candidates) },
|
||||
commitment,
|
||||
...(type === TYPE.ANSWER ? { bindingTag: tag } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** A full two-sided handshake, as the manager runs it. */
|
||||
async function handshake() {
|
||||
const A = await makePeer(ROLE.OFFER);
|
||||
const B = await makePeer(ROLE.ANSWER);
|
||||
const offerDescriptor = await makeDescriptor(chrome.turn_all.offer, TYPE.OFFER, A.commitment);
|
||||
const answerDescriptor = await makeDescriptor(
|
||||
chrome.turn_all.answer, TYPE.ANSWER, B.commitment, await bindingTag(digest, offerDescriptor));
|
||||
const transcript = buildTranscript({
|
||||
offerDescriptor, answerDescriptor, offerBlob: A.blob, answerBlob: B.blob,
|
||||
});
|
||||
return { A, B, offerDescriptor, answerDescriptor, transcript };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// key blob encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const A = await makePeer(ROLE.OFFER);
|
||||
const decoded = decodeKeyBlob(A.blob);
|
||||
assert.equal(decoded.version, KEY_BLOB_VERSION);
|
||||
assert.equal(decoded.role, ROLE.OFFER);
|
||||
// Both keys must survive to a usable CryptoKey — a blob that decodes but
|
||||
// cannot be imported is no better than one that fails outright.
|
||||
await subtle.importKey('spki', decoded.ecdhSpki, { name: 'ECDH', namedCurve: 'P-384' }, false, []);
|
||||
await subtle.importKey('spki', decoded.ecdsaSpki, { name: 'ECDSA', namedCurve: 'P-384' }, false, ['verify']);
|
||||
|
||||
rejects(() => decodeKeyBlob(new Uint8Array(0)), /empty/, 'empty blob');
|
||||
rejects(() => decodeKeyBlob(A.blob.subarray(0, A.blob.length - 1)), /truncated/, 'truncated blob');
|
||||
|
||||
for (const v of [0x00, 0x01, 0x03, 0xff]) {
|
||||
const x = Uint8Array.from(A.blob); x[0] = v;
|
||||
rejects(() => decodeKeyBlob(x), /unsupported key blob version/, `blob version 0x${v.toString(16)}`);
|
||||
}
|
||||
|
||||
const badRole = Uint8Array.from(A.blob); badRole[1] = 2;
|
||||
rejects(() => decodeKeyBlob(badRole), /reserved key blob role/, 'reserved role');
|
||||
|
||||
const trailing = new Uint8Array(A.blob.length + 2);
|
||||
trailing.set(A.blob); trailing.set([9, 9], A.blob.length);
|
||||
rejects(() => decodeKeyBlob(trailing), /trailing byte/, 'trailing bytes');
|
||||
|
||||
const huge = Uint8Array.from(A.blob); huge[2] = 0xff; huge[3] = 0xff;
|
||||
rejects(() => decodeKeyBlob(huge), /SPKI length out of range/, 'absurd SPKI length');
|
||||
|
||||
rejects(() => decodeKeyBlob(new Uint8Array(BLOB_LIMITS.MAX_BLOB_BYTES + 1)), /size limit/, 'oversized blob');
|
||||
rejects(() => encodeKeyBlob({ role: 7, ecdhSpki: new Uint8Array(60), ecdsaSpki: new Uint8Array(60) }),
|
||||
/invalid role/, 'encode with a bad role');
|
||||
rejects(() => encodeKeyBlob({ role: ROLE.OFFER, ecdhSpki: new Uint8Array(4), ecdsaSpki: new Uint8Array(60) }),
|
||||
/ecdh SPKI length out of range/, 'encode with a stub key');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the commitment gate
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const { A, B, offerDescriptor, answerDescriptor } = await handshake();
|
||||
|
||||
// The honest case: each side's descriptor commits to its own blob.
|
||||
await verifyBlobCommitment(subtle, A.blob, decodeDescriptor(offerDescriptor).commitment);
|
||||
await verifyBlobCommitment(subtle, B.blob, decodeDescriptor(answerDescriptor).commitment);
|
||||
|
||||
// Substitution: an attacker who can rewrite the in-band blob but not the
|
||||
// scanned descriptor is caught before the blob is ever parsed.
|
||||
const M = await makePeer(ROLE.ANSWER);
|
||||
await rejectsAsync(
|
||||
() => verifyBlobCommitment(subtle, M.blob, decodeDescriptor(answerDescriptor).commitment),
|
||||
/does not match the commitment/, 'substituted blob');
|
||||
|
||||
// A single flipped bit anywhere in the blob is enough.
|
||||
for (const idx of [0, 1, 5, 40, A.blob.length - 1]) {
|
||||
const tampered = Uint8Array.from(A.blob); tampered[idx] ^= 0x01;
|
||||
await rejectsAsync(
|
||||
() => verifyBlobCommitment(subtle, tampered, decodeDescriptor(offerDescriptor).commitment),
|
||||
/does not match the commitment/, `blob byte ${idx} flipped`);
|
||||
}
|
||||
|
||||
// Swapping the two peers' blobs is also a mismatch.
|
||||
await rejectsAsync(
|
||||
() => verifyBlobCommitment(subtle, B.blob, decodeDescriptor(offerDescriptor).commitment),
|
||||
/does not match the commitment/, 'blobs swapped');
|
||||
|
||||
// A descriptor with no commitment cannot be used to admit a blob.
|
||||
await rejectsAsync(() => verifyBlobCommitment(subtle, A.blob, null),
|
||||
/no usable commitment/, 'missing commitment');
|
||||
await rejectsAsync(() => verifyBlobCommitment(subtle, A.blob, new Uint8Array(8)),
|
||||
/no usable commitment/, 'short commitment');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// transcript, salt, SAS
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const h = await handshake();
|
||||
|
||||
// Both peers derive the same salt and the same digits from their own side.
|
||||
const salt = await deriveTranscriptSalt(subtle, h.transcript);
|
||||
assert.equal(salt.length, 64, 'deriveSharedKeys requires exactly 64 bytes');
|
||||
assert.ok(salt.every((b) => Number.isInteger(b) && b >= 0 && b <= 255));
|
||||
|
||||
const sasA = await computeTranscriptSas(subtle, {
|
||||
ecdhPrivateKey: h.A.ecdh.privateKey, peerEcdhPublicKey: h.B.ecdh.publicKey, transcript: h.transcript,
|
||||
});
|
||||
const sasB = await computeTranscriptSas(subtle, {
|
||||
ecdhPrivateKey: h.B.ecdh.privateKey, peerEcdhPublicKey: h.A.ecdh.publicKey, transcript: h.transcript,
|
||||
});
|
||||
assert.equal(sasA, sasB, 'both sides must read the same digits');
|
||||
assert.match(sasA, /^\d{7}$/, 'SAS is 7 digits');
|
||||
|
||||
// Every component of the transcript must move the digits and the salt.
|
||||
const variants = {
|
||||
'offer descriptor': { offerDescriptor: (x) => { const y = Uint8Array.from(x); y[6] ^= 0xff; return y; } },
|
||||
'answer descriptor': { answerDescriptor: (x) => { const y = Uint8Array.from(x); y[6] ^= 0xff; return y; } },
|
||||
'offer blob': { offerBlob: (x) => { const y = Uint8Array.from(x); y[10] ^= 0x01; return y; } },
|
||||
'answer blob': { answerBlob: (x) => { const y = Uint8Array.from(x); y[10] ^= 0x01; return y; } },
|
||||
};
|
||||
for (const [label, mut] of Object.entries(variants)) {
|
||||
const parts = {
|
||||
offerDescriptor: h.offerDescriptor, answerDescriptor: h.answerDescriptor,
|
||||
offerBlob: h.A.blob, answerBlob: h.B.blob,
|
||||
};
|
||||
for (const [k, fn] of Object.entries(mut)) parts[k] = fn(parts[k]);
|
||||
const t2 = buildTranscript(parts);
|
||||
const sas2 = await computeTranscriptSas(subtle, {
|
||||
ecdhPrivateKey: h.A.ecdh.privateKey, peerEcdhPublicKey: h.B.ecdh.publicKey, transcript: t2,
|
||||
});
|
||||
assert.notEqual(sas2, sasA, `SAS must change when the ${label} changes`);
|
||||
assert.notDeepEqual(await deriveTranscriptSalt(subtle, t2), salt,
|
||||
`the HKDF salt must change when the ${label} changes`);
|
||||
}
|
||||
|
||||
// Role order, not call order: a peer that assembled the transcript with the
|
||||
// sides swapped must not land on the same digits.
|
||||
const swapped = buildTranscript({
|
||||
offerDescriptor: h.answerDescriptor, answerDescriptor: h.offerDescriptor,
|
||||
offerBlob: h.B.blob, answerBlob: h.A.blob,
|
||||
});
|
||||
assert.notDeepEqual(swapped, h.transcript, 'transcript is role-ordered');
|
||||
|
||||
rejects(() => buildTranscript({
|
||||
offerDescriptor: h.offerDescriptor, answerDescriptor: h.answerDescriptor, offerBlob: h.A.blob,
|
||||
}), /transcript component answerBlob is missing/, 'incomplete transcript');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// identity proof (replaces authProof)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const h = await handshake();
|
||||
const payload = proofPayload(h.transcript);
|
||||
|
||||
const sigA = new Uint8Array(await subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.privateKey, payload));
|
||||
assert.equal(await subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.publicKey, sigA, payload), true, 'honest proof verifies');
|
||||
|
||||
// Wrong identity key: a peer that did not commit to this ECDSA key cannot
|
||||
// produce the proof.
|
||||
const M = await makePeer(ROLE.OFFER);
|
||||
const sigM = new Uint8Array(await subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-384' }, M.ecdsa.privateKey, payload));
|
||||
assert.equal(await subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.publicKey, sigM, payload), false, 'foreign key is rejected');
|
||||
|
||||
// A proof over a different transcript does not transfer: this is what stops
|
||||
// a signature captured from one session being replayed into another.
|
||||
const other = await handshake();
|
||||
assert.equal(await subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.publicKey, sigA, proofPayload(other.transcript)),
|
||||
false, 'proof does not transfer across sessions');
|
||||
|
||||
// The domain-separation label is part of what is signed, so a raw-transcript
|
||||
// signature is not a valid proof.
|
||||
assert.equal(await subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.publicKey, sigA, h.transcript),
|
||||
false, 'proof is domain-separated from the bare transcript');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// end-to-end: the sequence the manager performs, including a MITM attempt
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const h = await handshake();
|
||||
|
||||
// Each side checks the peer's commitment, then derives.
|
||||
await verifyBlobCommitment(subtle, h.B.blob, decodeDescriptor(h.answerDescriptor).commitment);
|
||||
await verifyBlobCommitment(subtle, h.A.blob, decodeDescriptor(h.offerDescriptor).commitment);
|
||||
|
||||
const salt = await deriveTranscriptSalt(subtle, h.transcript);
|
||||
const bits = async (priv, pub) => new Uint8Array(
|
||||
await subtle.deriveBits({ name: 'ECDH', public: pub }, priv, 256));
|
||||
assert.deepEqual(
|
||||
await bits(h.A.ecdh.privateKey, h.B.ecdh.publicKey),
|
||||
await bits(h.B.ecdh.privateKey, h.A.ecdh.publicKey),
|
||||
'both sides reach the same ECDH secret');
|
||||
assert.equal(salt.length, 64);
|
||||
|
||||
// MITM: an attacker who terminates DTLS to each side and forwards the
|
||||
// descriptors unchanged still has to present key material matching a
|
||||
// commitment it cannot recompute, because the commitment travelled inside
|
||||
// the descriptor the user carried.
|
||||
const M = await makePeer(ROLE.ANSWER);
|
||||
await rejectsAsync(
|
||||
() => verifyBlobCommitment(subtle, M.blob, decodeDescriptor(h.answerDescriptor).commitment),
|
||||
/does not match the commitment/, 'MITM key substitution');
|
||||
|
||||
// If the attacker rewrites the descriptor too — which requires control of
|
||||
// the out-of-band channel — the commitment check passes, and the SAS is
|
||||
// what catches it.
|
||||
const forged = await makeDescriptor(
|
||||
chrome.turn_all.answer, TYPE.ANSWER, M.commitment, await bindingTag(digest, h.offerDescriptor));
|
||||
await verifyBlobCommitment(subtle, M.blob, decodeDescriptor(forged).commitment);
|
||||
const forgedTranscript = buildTranscript({
|
||||
offerDescriptor: h.offerDescriptor, answerDescriptor: forged,
|
||||
offerBlob: h.A.blob, answerBlob: M.blob,
|
||||
});
|
||||
const honest = await computeTranscriptSas(subtle, {
|
||||
ecdhPrivateKey: h.A.ecdh.privateKey, peerEcdhPublicKey: h.B.ecdh.publicKey, transcript: h.transcript,
|
||||
});
|
||||
const attacked = await computeTranscriptSas(subtle, {
|
||||
ecdhPrivateKey: h.A.ecdh.privateKey, peerEcdhPublicKey: M.ecdh.publicKey, transcript: forgedTranscript,
|
||||
});
|
||||
assert.notEqual(attacked, honest, 'a fully rewritten handshake still changes the SAS digits');
|
||||
}
|
||||
|
||||
console.log('sbq2-key-exchange: all assertions passed');
|
||||
Reference in New Issue
Block a user