feat(groups): group chats, and a mesh rather than a star; release v6.1.1
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

A group is an orchestration layer over the pairwise sessions the app already
holds. It owns no transport and no shared key: every frame leaves over a chat
that is already SAS-verified and already ratcheted, so a removed member simply
stops being sent anything. Membership is a roster the admin signs, ordered by
epoch, and the safety code is a commit-then-reveal round over every member's
fingerprint and nonce.

Delivery was the part that did not match its own description. The admin held a
link to everyone and nobody else held a link to anybody, so the relay path — the
documented fallback — was in fact the entire topology, and the admin going away
partitioned the group. Now, once the code is confirmed, each pair without a link
dials one over that relay path. The descriptors are compact enough to ride a
group frame and are signed with the sender's group identity key, so the relaying
member can drop a dial but cannot substitute one. The member with the smaller
fingerprint dials, which is the whole glare protocol.

Mesh links are released without a human comparing digits. Twenty-eight codes for
a group of eight is not a check anyone performs; the guarantee moves rather than
disappears, since the descriptor was signed by a key the signed roster names and
the group code covers. markGroupLinkVerified refuses any session whose in-band
exchange has not completed and whose peer has not proved possession of that key.

An existing 1:1 chat between two members is adopted instead of re-dialled, via a
probe bound to that session's own key fingerprint so it cannot be replayed onto
another chat to impersonate its author.

Security fix: g_hello was accepted on any session from anyone who knew the group
id, so any member could publish an identity the admin never invited and have the
admin sign and broadcast a roster containing it. It is now accepted only on a
session an invitation went out on, which also confines it to a direct link.

Mesh connections are kept out of the chat registry and muted from the document
events the header listens to, so a routing detail cannot tear down the display of
a conversation the user actually opened.
This commit is contained in:
lockbitchat
2026-08-25 16:27:08 -04:00
parent 6a98e2eb00
commit e00c3bd413
30 changed files with 11635 additions and 421 deletions
+345
View File
@@ -1,5 +1,350 @@
# Changelog
## v6.1.1 — Group chats now connect everyone to everyone
Group chats used to run through whoever created them. That person held a
connection to every member, and nobody else was connected to anybody — so when
you wrote to someone in the group, your message went to the creator first and
they passed it along. It worked, but it meant one person could see when everyone
else was talking, and if they closed the app the group went quiet.
Now every pair in a group connects directly, by themselves. It happens on its own
a few seconds after everyone has confirmed the group code, and you can watch it
in the header: it goes from "2 of 3 connected" to "3 members · P2P mesh". If two
of you already had a private chat open with each other, the group just uses that
instead of building a second connection.
The practical difference: whoever created the group is now an ordinary member.
They can close the app and everyone else keeps talking.
### Also in this release
- When somebody can't be reached directly, their messages still travel through
another member — and a message that arrived that way is now marked "relayed",
so you can see when it happens.
- When somebody goes offline, the group says so once and by name, instead of
repeating a notice under every message you send until they come back.
- A connection that drops and can't be repaired is now rebuilt through someone
else, rather than staying dead for the rest of the conversation.
- The warning about relayed messages no longer appears when nothing is being
relayed — it used to count people who were simply offline.
- Group connections no longer interfere with the display of whichever chat you
happen to have open.
### Security
- **Fixed: any member of a group could add anyone else to it.** Only the person
who created the group was ever meant to be able to invite, but the check for
that was missing — a reply to an invitation was accepted from anyone who knew
the group's id, whether or not they had been invited. You would have noticed,
because a new member appears in the list and everyone's group code changes, but
noticing should not have been the only thing standing in the way.
- The details two members exchange to find each other travel through a third
member, so they are now signed. That member can refuse to pass them along, but
they cannot swap them for their own and end up sitting in the middle of the
connection.
- Nobody is asked to compare a code for each of these new connections. For a
group of eight that would be twenty-eight codes, which is not a check anyone
actually performs — the group code everyone already compared covers them.
## v6.0.8 — An offline group member reads as offline
### Fixed
- **A regression from 6.0.7: closing a departed peer's chat could strand a group
member.** That release removed the 1:1 chat when a peer disconnected, but a
group is built out of those very sessions — one of them is the group's only
route to that member. Tearing it out made them permanently unreachable and left
nothing to re-bind when they came back. A session carrying a group member is
now kept, on both the peer-departure and recovery-exhausted paths.
- **An offline member is now unmistakable in the member strip** — struck through,
dimmed, marked `offline`, with a tooltip saying they will not receive messages
and that removing them re-keys the group. They stay listed, because a dropped
connection is not a departure: membership is a signed, epoch-ordered fact, and
re-keying the group every time somebody's network hiccups would make everyone
re-compare a code for nothing. Removing a member is still available to the
admin, on the member chip, and that one does open a new epoch.
- **A relayed message is no longer counted as delivered when the recipient is
offline.** A relay hop is unacknowledged: the frame is handed to a member who
may or may not reach the target, and nothing comes back either way. For someone
we have never held a link to that is the normal path. For someone whose link we
lost it is a guess, and counting it told the sender their message had arrived
when there was no reason to believe it. The frame is still relayed — the target
may be reachable elsewhere in the mesh — it just no longer inflates the count.
## v6.0.7 — Dead chats clear themselves; the group code button is gone
### Removed
- **The Code button in the group header.** It opened a dialog that had nothing to
say once the group was ready — the code arrives on its own when there is
something to compare, and the header already carries it beside the member
count. A control that reports "Working…" forever is worse than no control.
### Fixed
- **A chat the peer walked out of no longer lingers in the rail.** `peer_disconnected`
comes only from an explicit peer_disconnect frame, never from a transport drop
an ICE restart might repair, so it is terminal: there is nothing to reconnect
to, and the chat is now removed rather than sitting there advertising a closing
notice as its last message. A plain `disconnected` is deliberately left alone —
that one is recoverable and keeps its history.
- **System notices no longer hijack the chat preview.** The rail showed whatever
message came last, so "Enhanced secure connection closed. Check connection
status." replaced the last thing the peer actually said — while the status line
right next to it was already saying the same thing, better. The preview now
skips system messages and falls back to the connection status when a chat has
nothing else.
## v6.0.6 — Invite into a running group; departed members actually disappear
### Added
- **The admin can invite more people into a group that is already open.** An Add
control in the group header offers the verified 1:1 chats that are not already
members. The group keeps working while the invitation is outstanding — nothing
about the membership changes until the new members publish their identity keys
and a roster for the next epoch goes out. Then everyone, old and new, runs a
fresh commit/reveal round and compares a new code. That is not ceremony for its
own sake: the safety code covers the member set, so a set that has changed has
a different code and the old one no longer describes who is in the room.
- If nobody accepts, the round is abandoned and the group is left exactly as it
was — which is why the epoch is not touched until the roster is actually sent.
A partial answer still publishes, with whoever joined.
### Fixed
- **A member who left stayed on the admin's list.** The admin removed them from
its own member map and then never told the interface, so someone who had
visibly left was still shown as present until some later event happened to
refresh the view. A group should not be vague about who is in it.
- **A fast invitation could abort a round that had already succeeded.** Whether
anything had been sent was judged by what was left in the pending-hello queue,
but on a fast link the invitee's hello comes back — and the whole round
completes — inside the very call that sent the invitation, so the queue was
legitimately empty by then. It is now judged by the send results.
## v6.0.5 — Group formation stops hitting the rate limiter
`frame_rejected` on one side, `ceremony_timed_out` on the other: two symptoms,
one cause.
### Fixed
- **Forming a group exceeded the transport's burst limit and lost frames.** The
manager allows ten sends per second, but a group frame spends *two* of those
slots — `sendMessage` checks the shared limiter and then hands off to
`sendSecureMessage`, which checks the same counter again. Formation sends six
frames back to back on one session (invite, two member keys, roster, commit,
reveal), asking for twelve slots out of ten. The overflow was rejected as a
plain `Error` carrying no code, so it reached the user as the meaningless
`frame_rejected`, while the peer that never received the dropped frame simply
waited until the ceremony timed out.
Group frames now go through a per-session queue that serialises and paces them
at five a second. Formation takes about a second and a half. The limiter itself
is untouched: widening a control that exists for the 1:1 chat, to suit a caller
that can perfectly well wait, would have been the wrong trade.
Ordering is enforced by the same queue. The protocol depends on it — a
commitment has to reach a peer before the reveal that opens it — and firing
several sends concurrently at one channel left that to the manager's internal
mutex.
- **A rate-limited frame is retried instead of dropped.** Losing one frame of a
handshake strands every member of the group, so it is worth waiting for.
Failures that will not improve on their own — a closed channel, a refused
verification gate — are still reported immediately rather than retried.
- **An error with no code of its own keeps its message.** Collapsing every such
failure to a bare `frame_rejected` threw away the only clue about what actually
happened, which is what made this bug take three rounds to find.
### Added
- `group-sender.test.mjs` reproduces the limiter's real accounting — ten slots a
second, two per frame — and asserts that all six formation frames land, in
order, spaced. It also asserts the failure directly: with pacing disabled, the
same run is rejected, and the rejection carries no `code`, which is exactly how
it reached the user.
## v6.0.4 — The group code appears, and a failed group says so
Three bugs, one visible symptom: the safety code never showed and the confirm
button stayed disabled. They are listed worst-first.
### Fixed
- **A failed ceremony could be confirmed into a working group.** `CONFIRM_SAS`
required only that a code existed. A ceremony that reached the code step and
*then* failed — a commitment that did not match its reveal, a member that
vanished — kept that code, so confirming promoted a group whose verification had
demonstrably gone wrong straight to ready. Confirmation now also requires the
group to still be waiting on that code, which is the group's version of the 1:1
rule that verified state comes only from the user acting on something currently
true.
- **A group name in a non-Latin script broke formation silently.** The create
dialog capped input at 64 *characters* while the protocol enforces 64 *bytes*,
so a perfectly ordinary 36-character Cyrillic name is 68 bytes: accepted by the
dialog, then rejected inside the admin's roster signing. Formation died with
nothing on screen and the invitee waited for a member list that was never sent.
The budget is now 128 bytes and every place that trims a name — the dialog, the
rename action — counts bytes.
- **The safety-code dialog reported "Exchanging nonces…" for a group that had
already failed.** It only distinguished one phase from everything else, so a
dead group was indistinguishable from a working one; the disabled confirm button
was the only hint. It now names the real state and, on failure, says what went
wrong in words worth acting on and offers Close instead of a confirm button that
cannot do anything.
### Added
- **Coverage for the seam between the protocol and the screen.** Every previous
group test drove `GroupSession` and read its fields, leaving the path the UI
renders from — emitted event, dispatched action, reducer state — untested. That
is exactly where these bugs lived. `group-app-integration.test.mjs` mirrors the
app's emitter and asserts the code reaches the *store* on both sides, including
with a Cyrillic group name, and that a failed group is never confirmable.
## v6.0.3 — New connection animation, and a re-ordered roadmap
### Changed
- **The landing page's left panel has a new animation.** The old one was a single
static wire between two avatars with dots sliding along it. The replacement is
a 14-second loop that tells the whole story: a direct line to one peer, then
two more joining it — which is what v6.0 actually shipped. It is drawn as one
SVG and animated entirely in CSS, with packets moving along `offset-path` built
from the same geometry the lines are drawn from, so a packet can never drift
off its wire. No requestAnimationFrame loop, so a landing page left open costs
nothing. Under `prefers-reduced-motion` it holds on the finished state rather
than disappearing, so the picture still reads as a mesh of three.
- **Mobile Edition moved up to v6.5 and is marked in development**, swapping with
Quantum-Resistant Edition, which moves to v7.0.
## v6.0.2 — Groups form reliably, and leaving one frees its members
### Fixed
- **A group no longer sticks at "Exchanging nonces".** Held commitments were
replayed *before* our own commitment went out, and replaying them could
complete the commit round on the spot — which revealed our nonce and put a
reveal on the wire ahead of the commitment it belongs to. The peer then held a
reveal it could not check yet and waited for a commitment that was already
behind it in the queue, so both sides sat there: one at "exchanging nonces",
the other still collecting commitments. Every member now broadcasts its own
commitment first and replays held frames afterwards, so a reveal can never
outrun it. Asserted directly by a wire-order test on the smallest case, a group
of two, which is where it was reported.
- **Leaving a group actually ends it for the other side.** `leave()` was started
but not awaited before teardown, and teardown clears the member map that
`leave()` walks to find recipients. Worse, a member who did hear it kept the
group anyway: only the admin acted on a departure. When the admin leaves there
is nobody left who can sign a roster, so there is no next epoch and no code to
compare again — the group is over, and it is now removed instead of lingering
as a chat that can never send.
- **Removing the second-to-last member no longer breaks the group.** It tried to
publish a roster for a group of one, which threw out of the member-list
validator part-way through the removal and left the group in a failed state.
The floor is now checked first and reported.
- **An invitation that cannot be sent says so immediately.** Send failures were
swallowed, so inviting someone over a dead link produced forty-five seconds of
silence and then a generic timeout — indistinguishable from an invitee who had
not answered yet. A send that fails for every invitee now fails at once, naming
the cause; if only some links are down, the unreachable invitees are dropped
from the round instead of stalling the roster forever.
### Changed
- **The roadmap reflects what shipped.** Group Communications and Mobile Edition
swap places: groups are v6.0 and current, native mobile apps move to v7.0, and
Secure Voice & Calls is marked released rather than current. The group entry
also lists what was actually built instead of what was guessed at — there is no
Double Ratchet "for groups" and no ephemeral-group mode; there are pairwise
ratchets, a commit-then-reveal safety code, and signed membership.
## v6.0.1 — The group code now actually appears
### Fixed
- **The safety code opens on its own again.** Whether to show it was decided by
comparing the finishing group against a ref that React assigns during render.
The commit/reveal round completes in a run of microtasks that outpaces the
re-render, so that ref was still null and the modal never opened — leaving a new
group sitting silently at "Compare the group code" with the digits reachable
only through a button. The group that produced a code is now brought forward
directly, which both shows the modal and guarantees it is pointed at the right
group when more than one is forming.
## v6.0.0 — Group chats
Up to eight people in one peer-to-peer conversation, with no server and no shared
group key. A group is an orchestration layer over the 1:1 sessions that already
exist: every group message travels over a pairwise Double Ratchet that was already
verified, so the group inherits the forward secrecy and the transport of the chats
it is built from rather than introducing a second, weaker path.
### Added
- **Group chats, up to 8 members.** Create one from the 1:1 chats you have already
verified. Unverified chats are not offered: a group built on a session whose
safety code was never compared would inherit that open question and hide it
behind a group code that looks like it settled the matter.
- **A group safety code, compared once by everybody.** Seven digits, the same
length as the pairwise code, derived from every member's group identity key and
a secret nonce each of them contributes. Nothing is sent until the humans
confirm it.
- **Commit-then-reveal, which is what makes seven digits enough.** The obvious
construction — hash the member keys and show the digits — is not safe at a
length people will read aloud. A member who introduces two others controls what
each of them sees and can generate candidate keys until the two truncated hashes
collide; that is a birthday search of roughly 10^(d/2), and seven digits falls
in a few thousand tries. Signal answers this by making the safety number sixty
digits. Here every member instead publishes a hash of a secret nonce first, and
no nonce is revealed until every commitment has arrived — so an attacker must
fix both of their commitments before seeing a single honest nonce and is left
guessing once, at 10⁻⁷. The gate has one implementation and refuses to reveal
early, including on a timeout.
- **Signed membership, ordered by epochs.** Every change of membership is signed
by the group's admin over the resulting member set, and only a strictly greater
epoch is accepted — which refuses both a replay and a rollback to a membership
that used to be valid. Removing a member opens a new epoch, so the group re-keys
and everyone compares a fresh code.
- **Signed messages, so a split transcript is provable.** Messages fan out over
N-1 independent ratchets, so each recipient only learns that the sender's
session sent it. A signature over (group, epoch, sequence, body hash) means a
member who tells two halves of the group different things under one sequence
number produces two valid signatures — non-repudiable evidence rather than
suspicion. The offending message is discarded and named in the transcript.
- **Delivery over a direct link where one exists, relayed where it does not.** A
full mesh needs N(N-1)/2 pairwise connections and at creation only the admin
holds a link to everyone. Rather than block the group, a frame for an
unreachable member is handed to a member who can reach you both. Because every
frame is signed, a relaying member can drop or read a frame — reading is what
membership already entitles them to — but cannot forge, alter or reattribute
one. Relaying is single-hop by construction, and the member strip shows who is
direct and who is not.
- **Partial delivery is reported.** With no server there is nobody to hold a
message for an absent member, so a send that reached only some of the group says
so in the transcript instead of looking like it succeeded.
- **A warning before you create a group without relay-only.** In a mesh every
member connects to you directly and learns your address — including members
somebody else invited. That is a real step down from a 1:1 chat and it is said
before you commit, not after.
### Notes
- The group layer required **no changes to the WebRTC manager**. Group frames ride
`sendMessage`, the same authenticated and ratcheted path chat text takes, and
are lifted out before they can reach a 1:1 transcript.
- Frames travel base64-wrapped. The chat path sanitises what it sends — DOMPurify
escapes `<`, `>` and `&`, control characters are stripped, blank runs collapse,
and the result is cut to 2000 characters — all of which is correct for chat text
and fatal for a signed frame, whose body would no longer match the hash its
signature covers. Base64 has no character that path rewrites. A group message is
bounded at 1024 bytes so a frame always fits underneath the same ceiling.
- There is no message history for a member who joins: a group starts empty, and
without a server there is nothing to backfill it from.
- Group calls are not in this release.
## v5.9.2 — Responsive layout fixed for phones
The chat was not laying out correctly on phones, iPhone worst of all: the header
+1 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-5.9.2-3ecf8e.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-6.1.1-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
[![Forward secrecy](https://img.shields.io/badge/forward%20secrecy-Double%20Ratchet-3ecf8e.svg)](#forward-secrecy)
+1 -1
View File
File diff suppressed because one or more lines are too long
+93 -27
View File
@@ -8354,6 +8354,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
iceServers: config.webrtc?.iceServers ?? _EnhancedSecureWebRTCManager.DEFAULT_ICE_SERVERS.map((server) => ({ ...server }))
}
};
this._emitGlobalEvents = config.emitGlobalEvents !== false;
this._ipLeakWarningShown = false;
this._initializeSecureLogging();
this._setupOwnLogger();
@@ -11251,6 +11252,59 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.onStatusChange("disconnected");
}
}
/**
* Release a link that a GROUP authenticated, with no human in the loop.
*
* WHY THIS IS NOT A BYPASS
* ------------------------
* The SAS comparison exists to answer one question: is the peer who
* completed this handshake the person we meant to talk to? For a 1:1 chat
* only a human can answer it, which is why _setVerifiedStatus refuses every
* SAS-shaped transition that no human confirmed.
*
* A mesh link inside a group has already answered it, earlier and by a
* different route. The descriptor that opened this connection was signed
* with a group identity key; that key's fingerprint is named in a roster
* signed by the admin; and the group's safety code which every member
* compared out of band before any of this was allowed to start covers
* that exact set of fingerprints. Asking the two people to also read seven
* digits at each other for every one of up to twenty-eight pairs would not
* add a check, it would repeat one they already did, badly.
*
* So the guarantee is not weakened here, it is moved: the caller must have
* verified the group signature over the peer's descriptor BEFORE the
* transport was created. Everything this method can check for itself, it
* does the session must be SBQ2, its in-band exchange must have completed,
* and the peer must have proved possession of the identity key that the
* commitment in that descriptor bound it to. A session that has not got that
* far is refused outright rather than released on the caller's word.
*
* @param {string} reason short audit label for why the group vouched
*/
markGroupLinkVerified(reason = "group_roster_signature") {
const st = this._sbq2;
if (!this._isSbq2() || !st || !st.completed || !st.proofVerified || !st.keysDerived) {
throw new Error("Group link cannot be released: the in-band handshake has not completed");
}
if (!this.encryptionKey || !this.macKey) {
throw new Error("Group link cannot be released: session keys are missing");
}
if (this.isVerified) return true;
this.localVerificationConfirmed = true;
this.remoteVerificationConfirmed = true;
this.bothVerificationsConfirmed = true;
this._setVerifiedStatus(true, "GROUP_ROSTER_SIGNATURE", {
reason,
timestamp: Date.now()
});
this._enforceVerificationGate("group_link_release", false);
this.onStatusChange?.("verified");
try {
this.processMessageQueue();
} catch (_) {
}
return true;
}
/**
* Create AAD (Additional Authenticated Data) for file messages
* This binds file messages to the current session and prevents replay attacks
@@ -11669,6 +11723,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
// ========================================================================
// SBQ2 — compact descriptor + in-band key exchange
// ========================================================================
/**
* Announce a lifecycle change to the application, unless this connection is
* muted. See `_emitGlobalEvents` in the constructor for why one would be.
*/
_dispatchAppEvent(event) {
if (!this._emitGlobalEvents) return false;
try {
return document.dispatchEvent(event);
} catch (_) {
return false;
}
}
/** True once this connection has latched onto the SBQ2 handshake. */
_isSbq2() {
return this._handshakeMode === "sbq2";
@@ -14417,7 +14483,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
hasKeys: !!(this.encryptionKey && this.macKey && this.metadataKey),
hasLastCalculation: !!this.lastSecurityCalculation
});
document.dispatchEvent(new CustomEvent("security-level-updated", {
this._dispatchAppEvent?.(new CustomEvent("security-level-updated", {
detail: {
timestamp: Date.now(),
manager: "webrtc",
@@ -14431,7 +14497,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
setTimeout(() => {
}, 100);
if (this.lastSecurityCalculation) {
document.dispatchEvent(new CustomEvent("real-security-calculated", {
this._dispatchAppEvent?.(new CustomEvent("real-security-calculated", {
detail: {
securityData: this.lastSecurityCalculation,
webrtcManager: this,
@@ -14624,7 +14690,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
isRealCalculation: securityData.isRealData
});
this.lastSecurityCalculation = securityData;
document.dispatchEvent(new CustomEvent("real-security-calculated", {
this._dispatchAppEvent?.(new CustomEvent("real-security-calculated", {
detail: {
securityData,
webrtcManager: this,
@@ -16873,7 +16939,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
capabilitiesCount: 10
// All capabilities enabled by default
});
document.dispatchEvent(new CustomEvent("new-connection", {
this._dispatchAppEvent?.(new CustomEvent("new-connection", {
detail: {
type: "offer",
timestamp: currentTimestamp,
@@ -17026,7 +17092,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
const { text: text2 } = await this._sbq2BuildDescriptor(TYPE.ANSWER, {
bindingTag: await bindingTag(digest, offerBytes)
});
document.dispatchEvent(new CustomEvent("new-connection", {
this._dispatchAppEvent?.(new CustomEvent("new-connection", {
detail: { type: "answer", timestamp: Date.now(), operationId }
}));
return { t: "answer", sbq2: text2 };
@@ -17477,7 +17543,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
timestamp: currentTimestamp,
processingTime: currentTimestamp - offerData.timestamp
});
document.dispatchEvent(new CustomEvent("new-connection", {
this._dispatchAppEvent?.(new CustomEvent("new-connection", {
detail: {
type: "answer",
timestamp: currentTimestamp,
@@ -18755,7 +18821,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.onStatusChange("connected");
this.processMessageQueue();
try {
document.dispatchEvent(new CustomEvent("connection-recovered", {
this._dispatchAppEvent?.(new CustomEvent("connection-recovered", {
detail: { timestamp: Date.now() }
}));
} catch (_) {
@@ -19115,7 +19181,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.fileTransferSystem.cleanup();
this.fileTransferSystem = null;
}
document.dispatchEvent(new CustomEvent("peer-disconnect", {
this._dispatchAppEvent?.(new CustomEvent("peer-disconnect", {
detail: {
reason: "connection_lost",
timestamp: Date.now()
@@ -19186,7 +19252,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.stopHeartbeat();
this.onKeyExchange("");
this.onVerificationRequired("");
document.dispatchEvent(new CustomEvent("peer-disconnect", {
this._dispatchAppEvent?.(new CustomEvent("peer-disconnect", {
detail: {
reason,
timestamp: Date.now()
@@ -19298,13 +19364,13 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
errorType: error?.constructor?.name || "Unknown"
});
});
document.dispatchEvent(new CustomEvent("peer-disconnect", {
this._dispatchAppEvent?.(new CustomEvent("peer-disconnect", {
detail: {
reason: "user_disconnect",
timestamp: Date.now()
}
}));
document.dispatchEvent(new CustomEvent("connection-cleaned", {
this._dispatchAppEvent?.(new CustomEvent("connection-cleaned", {
detail: {
timestamp: Date.now(),
reason: "user_cleanup"
@@ -19791,7 +19857,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
if (typeof document !== "undefined") {
try {
document.dispatchEvent(new CustomEvent("securebit-call-state", {
this._dispatchAppEvent?.(new CustomEvent("securebit-call-state", {
detail: { managerId: this._managerId || null, state: snapshot }
}));
} catch (_) {
@@ -21266,7 +21332,7 @@ var SecureMasterKeyManager = class {
var import_NotificationIntegration = __toESM(require_NotificationIntegration());
// package.json
var version = "5.9.2";
var version = "6.1.1";
// src/components/ui/Header.jsx
var APP_VERSION = `v${version}`;
@@ -22382,33 +22448,33 @@ function Roadmap() {
v: "v5.5",
title: "Secure Voice & Calls",
sub: "Encrypted voice messages, audio calls, and video calls",
status: "current",
date: "Now",
status: "released",
date: "Early 2026",
features: ["End-to-end encrypted voice messages", "1:1 encrypted audio calls (WebRTC)", "1:1 encrypted video calls (WebRTC)", "Perfect Forward Secrecy for live media", "SRTP/DTLS-protected media streams", "In-call SAS verification", "Call notifications and auto-reconnection", "Low-latency P2P media"]
},
{
v: "v6.0",
title: "Mobile Edition",
sub: "Native mobile apps for iOS and Android",
status: "dev",
date: "Q4 2026",
features: ["iOS native app (Swift/SwiftUI)", "Android native app (Kotlin/Jetpack Compose)", "PWA support for mobile browsers", "Real-time push notifications", "Battery optimization", "Mobile-optimized UX/UI", "Offline message queuing", "Biometric authentication"]
title: "Group Communications",
sub: "Group chats with preserved privacy",
status: "current",
date: "Now",
features: ["P2P group chats up to 8 participants", "Mesh delivery with signed relay fallback", "One group safety code, compared by everyone", "Commit-then-reveal ceremony against code grinding", "Per-group identity keys, ephemeral by design", "Signed membership with epoch ordering", "Signed messages, so a split transcript is provable", "No server, no shared group key, no history"]
},
{
v: "v6.5",
title: "Quantum-Resistant Edition",
sub: "Protection against quantum computers",
status: "planned",
title: "Mobile Edition",
sub: "Native mobile apps for iOS and Android",
status: "dev",
date: "Q2 2027",
features: ["Post-quantum cryptography CRYSTALS-Kyber", "SPHINCS+ digital signatures", "Hybrid scheme: classic + PQ", "Quantum-safe key exchange", "Updated hashing algorithms", "Migration of existing sessions", "Compatibility with v5.x", "Quantum-resistant protocols"]
features: ["iOS native app (Swift/SwiftUI)", "Android native app (Kotlin/Jetpack Compose)", "PWA support for mobile browsers", "Real-time push notifications", "Battery optimization", "Mobile-optimized UX/UI", "Offline message queuing", "Biometric authentication"]
},
{
v: "v7.0",
title: "Group Communications",
sub: "Group chats with preserved privacy",
title: "Quantum-Resistant Edition",
sub: "Protection against quantum computers",
status: "planned",
date: "Q4 2027",
features: ["P2P group connections up to 8 participants", "Mesh networking for groups", "Signal Double Ratchet for groups", "Anonymous groups without metadata", "Ephemeral groups (disappear after session)", "Cryptographic group administration", "Group member auditing"]
features: ["Post-quantum cryptography CRYSTALS-Kyber", "SPHINCS+ digital signatures", "Hybrid scheme: classic + PQ", "Quantum-safe key exchange", "Updated hashing algorithms", "Migration of existing sessions", "Compatibility with v5.x", "Quantum-resistant protocols"]
},
{
v: "v7.5",
+2 -2
View File
File diff suppressed because one or more lines are too long
Vendored
+3953 -298
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
File diff suppressed because one or more lines are too long
+72 -2
View File
@@ -7,7 +7,7 @@ this document describes.
| | |
| --- | --- |
| Release | v5.9.2 |
| Release | v6.1.1 |
| Protocol version | 4.1 |
| Ratchet wire version | 1 |
@@ -236,7 +236,77 @@ what protects it. Functions that cannot wipe say so in their logs instead of
reporting success, because a cleanup path that reports work it did not do is
worse than one that reports nothing.
## Groups
A group owns no transport and no key of its own. Every byte it sends leaves over
one of the pairwise sessions described above, already ratcheted and already
authenticated by that session. There is no shared group key, so there is nothing
to rotate when a member leaves: a removed member simply stops being sent
anything.
Each member holds a per-group ECDSA P-384 identity key, generated in the browser,
non-extractable, and discarded with the group. A member is identified by the
SHA-256 fingerprint of that key's SPKI encoding — never by a session, never by a
name a peer supplied.
### Membership
The admin signs the full member set for an epoch: group id, epoch, operation,
name, and the member fingerprints in canonical order. Members verify that
signature against the key whose fingerprint they recorded when they were invited,
not against whatever key the frame carries, and accept only an epoch that has not
gone backwards. Identity keys travel in separate frames because eight SPKIs would
overrun the transport's frame budget; each is checked against the fingerprint the
signed roster commits to, so a substituted key is refused whichever frame carried
it.
### Group safety code
Every member commits to a random 32-byte nonce, and nonces are published only
once every commitment has arrived. The code is derived with HKDF-SHA-256 over the
full set of fingerprints and nonces for that epoch, and rendered as seven digits.
The commit-then-reveal ordering is what makes seven digits safe: a member who
learnt the others' nonces first could otherwise grind its own until the digits
came out however it liked. Membership changes open a new epoch, so the code
changes with the member set and the old one no longer says anything about who is
in the room.
### Messages
Group messages are signed with the sender's identity key over group id, epoch,
sequence number, sender fingerprint and the SHA-256 of the body. Signing is not
about confidentiality — each copy already travels inside a pairwise session — but
about consistency: a member could otherwise send different text to different
people under one sequence number and no recipient could tell. Two valid
signatures on one sequence number are non-repudiable evidence of that, which is
what a group without a shared transcript can honestly offer. It makes the split
detectable, not impossible.
### The mesh
A group starts as a star and dials itself into a mesh: once the code is
confirmed, each pair with no link between them opens one, with the compact
descriptors travelling over the relay path that already exists. The member with
the smaller fingerprint dials, which is the whole glare protocol.
A relayed descriptor is signed with the sender's group identity key, over the
direction, both fingerprints, a per-attempt nonce and the descriptor bytes. The
relaying member can drop a dial or delay it; it cannot substitute one, so it
cannot place itself inside the link built to route around it. On the new
connection the ordinary in-band key exchange runs unchanged, and the link is then
released on the group's authority rather than by a human comparing digits — the
descriptor was signed by a key the roster names and the group code already
covers. The release path refuses any session whose in-band handshake has not
completed and whose peer has not proved possession of that key.
An existing 1:1 chat between two members is claimed instead of re-dialled, using
a probe signed over the group id, epoch, member fingerprint and that session's
own key fingerprint. Both endpoints of a session derive the same key fingerprint
from the shared secret and nobody else can, so a probe replayed onto a different
chat does not verify — which is what stops a member claiming to be someone else
and receiving their group traffic.
## Scope
This describes the browser implementation as it stands in v5.9.2. It is not a
This describes the browser implementation as it stands in v6.1.1. It is not a
substitute for independent cryptographic review.
+22 -22
View File
@@ -28,7 +28,7 @@
<!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786294476636">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1787689263870">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
@@ -94,7 +94,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=1786294476636">
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1787689263870">
<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">
@@ -103,7 +103,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=1786294476636">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1787689263870">
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35">
@@ -187,7 +187,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=1786294476636"></script>
<script defer src="config/ice-servers.js?v=1787689263870"></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 —
@@ -195,8 +195,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=1786294476636">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786294476636">
<link rel="stylesheet" href="assets/tailwind.css?v=1787689263870">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1787689263870">
<!-- 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. -->
@@ -204,31 +204,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=1786294476636">
<link rel="stylesheet" href="src/styles/animations.css?v=1786294476636">
<link rel="stylesheet" href="src/styles/components.css?v=1786294476636">
<link rel="stylesheet" href="src/styles/main.css?v=1787689263870">
<link rel="stylesheet" href="src/styles/animations.css?v=1787689263870">
<link rel="stylesheet" href="src/styles/components.css?v=1787689263870">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1786294476636"></script>
<script defer src="src/scripts/load-async-css.js?v=1787689263870"></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=1786294476636"></script>
<script defer src="src/scripts/fa-check.js?v=1787689263870"></script>
<!-- Update Manager - система принудительного обновления -->
<script defer src="src/utils/updateManager.js?v=1786294476636"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1786294476636"></script>
<script type="module" src="dist/qr-local.js?v=1786294476636"></script>
<script type="module" src="src/components/QRScanner.js?v=1786294476636"></script>
<script defer src="src/utils/updateManager.js?v=1787689263870"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1787689263870"></script>
<script type="module" src="dist/qr-local.js?v=1787689263870"></script>
<script type="module" src="src/components/QRScanner.js?v=1787689263870"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1786294476636"></script>
<script type="module" src="dist/app.js?v=1786294476636"></script>
<script type="module" src="dist/app-boot.js?v=1787689263870"></script>
<script type="module" src="dist/app.js?v=1787689263870"></script>
<script defer src="src/scripts/pwa-register.js?v=1786294476636"></script>
<script src="./src/pwa/install-prompt.js?v=1786294476636" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1786294476636" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1786294476636"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786294476636">
<script defer src="src/scripts/pwa-register.js?v=1787689263870"></script>
<script src="./src/pwa/install-prompt.js?v=1787689263870" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1787689263870" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1787689263870"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1787689263870">
</body>
</html>
+7 -7
View File
@@ -1,10 +1,10 @@
{
"version": "1786294476636",
"buildVersion": "1786294476636",
"appVersion": "5.9.2",
"buildTime": "2026-08-09T16:54:36.685Z",
"buildId": "1786294476636-556727e",
"gitHash": "556727e",
"version": "1787689263870",
"buildVersion": "1787689263870",
"appVersion": "6.1.1",
"buildTime": "2026-08-25T20:21:03.926Z",
"buildId": "1787689263870-6a98e2e",
"gitHash": "6a98e2e",
"generated": true,
"generatedAt": "2026-08-09T16:54:36.686Z"
"generatedAt": "2026-08-25T20:21:03.928Z"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "securebit-chat",
"version": "5.9.2",
"version": "6.1.1",
"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 && node tests/sbq2-key-exchange.test.mjs && node tests/qr-scan-single-frame.test.mjs && node tests/mobile-chat-layout.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 && node tests/qr-scan-single-frame.test.mjs && node tests/mobile-chat-layout.test.mjs && node tests/group-crypto.test.mjs && node tests/groups-reducer.test.mjs && node tests/group-session-e2e.test.mjs && node tests/group-app-integration.test.mjs && node tests/group-sender.test.mjs && node tests/group-mesh.test.mjs"
},
"keywords": [
"p2p",
+875 -36
View File
File diff suppressed because it is too large Load Diff
+700
View File
@@ -0,0 +1,700 @@
// Group chat surfaces: the conversation view, the safety-code ceremony, the
// create dialog and the inbound invitation.
//
// These render from the reducer's group entry and call back into the app; they
// hold no protocol state and no key material. The safety-code modal is the one
// piece here that carries security weight, so it is deliberately blunt: it shows
// the digits, says who has to compare them and how, and offers no way to skip.
//
// React is a global in this app (loaded before the bundle), matching app.jsx.
import { GROUP_PHASE, MEMBER_STATE, groupInitials } from '../../state/groupsStore.js';
import { GROUP_LIMITS } from '../../group/groupCrypto.js';
const h = (...args) => React.createElement(...args);
const C = {
bg: '#0c0c0e',
panel: '#141417',
panel2: '#1b1b1f',
line: 'rgba(255,255,255,0.07)',
line2: 'rgba(255,255,255,0.13)',
ink: '#f4f4f6',
ink2: '#a7a7b0',
ink3: '#6b6b73',
accent: '#f0892a',
good: '#3ecf8e',
warn: '#e3b341',
bad: '#e5727a',
mono: "'JetBrains Mono', ui-monospace, monospace",
};
const ICON = {
users: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19"/><circle cx="10" cy="8" r="3.2"/><path d="M20 19v-1.5a3.5 3.5 0 0 0-2.6-3.4"/><path d="M15.5 5.3a3.2 3.2 0 0 1 0 5.4"/></svg>',
send: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>',
shield: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>',
x: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
plus: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
relay: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h4l3-7 4 14 3-7h2"/></svg>',
};
const svg = (markup, extra = {}) => h('span', {
style: { display: 'grid', placeItems: 'center', ...extra },
dangerouslySetInnerHTML: { __html: markup },
});
const btn = (accent = false) => ({
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '8px',
padding: '11px 18px', borderRadius: '10px', cursor: 'pointer',
fontFamily: 'inherit', fontSize: '14px', fontWeight: 700,
border: accent ? 'none' : `1px solid ${C.line2}`,
background: accent ? C.accent : 'transparent',
color: accent ? '#1a0f04' : C.ink2,
});
const overlay = {
position: 'fixed', inset: 0, zIndex: 90, display: 'grid', placeItems: 'center',
background: 'rgba(5,5,7,0.72)', backdropFilter: 'blur(6px)', padding: '20px',
};
const card = {
width: '100%', maxWidth: '440px', background: C.panel, border: `1px solid ${C.line}`,
borderRadius: '16px', padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px',
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
};
/** Trim a string so its UTF-8 encoding fits `max` bytes, never mid-character. */
function clampToBytes(value, max) {
const enc = new TextEncoder();
let out = String(value);
while (enc.encode(out).length > max) out = out.slice(0, -1);
return out;
}
const label = {
fontFamily: C.mono, fontSize: '10px', fontWeight: 700, letterSpacing: '1.3px',
textTransform: 'uppercase', color: C.ink3,
};
// ---------------------------------------------------------------------------
// the safety-code ceremony
// ---------------------------------------------------------------------------
/**
* The group's safety code.
*
* Every member sees the same seven digits, and the comparison has to happen over
* something an attacker cannot impersonate. The copy says so plainly, because
* this is the step that distinguishes the intended group from a member who
* introduced two people and sat between them the code is the only thing that
* catches it, and a user who dismisses it has verified nothing.
*/
/**
* What to say while there is no code yet.
*
* This used to collapse to "Exchanging nonces…" for every phase that was not
* COMMITTING which included FAILED. A group that had actually died therefore
* looked identical to one still working, the confirm button stayed disabled, and
* the only visible symptom was a dialog that never finished. Naming the real
* state is the difference between a hang and a diagnosis.
*/
function waitingWord(group) {
switch (group.phase) {
case GROUP_PHASE.FORMING: return 'Waiting for the other members to join…';
case GROUP_PHASE.COMMITTING: return 'Waiting for every member to commit…';
case GROUP_PHASE.REVEALING: return 'Exchanging nonces…';
case GROUP_PHASE.FAILED: return GROUP_ERROR_WORD[group.error] || 'This group could not be formed.';
default: return 'Working…';
}
}
/** Failure codes from GroupSession, in words a person can act on. */
const GROUP_ERROR_WORD = {
invitations_could_not_be_sent: 'The invitation could not be sent — that chat is not connected.',
invitees_did_not_respond: 'Nobody accepted the invitation in time.',
roster_never_arrived: 'The group owner never sent the member list.',
ceremony_timed_out: 'A member stopped responding before the code was ready.',
bad_signature: 'The member list was not signed by the group owner. Do not retry — tell them.',
wrong_admin: 'Someone other than the group owner tried to change the members.',
fingerprint_mismatch: 'A members key did not match the identity claimed for it.',
commitment_mismatch: 'A members revealed value did not match what they committed to.',
commitment_changed: 'A member changed their commitment part-way through.',
missing_member_key: 'A member was listed whose key never arrived.',
not_a_member: 'A frame arrived from someone outside the group.',
bad_name: 'The group name is too long.',
too_many_members: 'A group is limited to eight members.',
frame_too_large: 'A message was too large to send to the group.',
};
export function GroupSasModal({ group, onConfirm, onCancel }) {
if (!group) return null;
const failed = group.phase === GROUP_PHASE.FAILED;
const waiting = group.phase !== GROUP_PHASE.AWAITING_SAS || !group.sasCode;
return h('div', { style: overlay, role: 'dialog', 'aria-modal': 'true' },
h('div', { style: card }, [
h('div', { key: 'h', style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, [
h('span', { key: 'l', style: label }, 'Group safety code'),
h('h3', { key: 't', style: { margin: 0, fontSize: '19px', fontWeight: 700, color: C.ink } }, group.name),
]),
waiting
? h('div', {
key: 'wait',
style: {
padding: '28px 16px', textAlign: 'center', borderRadius: '12px',
background: C.panel2, border: `1px solid ${C.line}`, color: C.ink2, fontSize: '14px',
},
}, [
h('div', {
key: 'd',
style: {
fontFamily: C.mono, fontSize: '26px', letterSpacing: '6px',
color: failed ? C.bad : C.ink3,
},
}, '·······'),
h('div', { key: 's', style: { marginTop: '10px', color: failed ? C.bad : C.ink2 } }, waitingWord(group)),
failed && h('div', {
key: 'why',
style: { marginTop: '6px', fontFamily: C.mono, fontSize: '11px', color: C.ink3 },
}, group.error || 'unknown'),
])
: h('div', {
key: 'code',
style: {
padding: '22px 16px', textAlign: 'center', borderRadius: '12px',
background: 'rgba(240,137,42,0.09)', border: '1px solid rgba(240,137,42,0.3)',
},
}, h('span', {
style: {
fontFamily: C.mono, fontSize: 'clamp(30px, 9vw, 42px)', fontWeight: 700,
letterSpacing: '9px', color: C.accent,
},
}, group.sasCode)),
h('p', {
key: 'why',
style: { margin: 0, fontSize: '13.5px', lineHeight: 1.62, color: C.ink2 },
}, [
'Read these digits aloud to ', h('b', { key: 'b', style: { color: C.ink } }, `all ${group.members.length - 1} other members`),
' — in person, or on a call where you recognise every voice. Everyone must see the same code.',
]),
h('p', {
key: 'warn',
style: {
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: 'rgba(229,114,122,0.09)', border: '1px solid rgba(229,114,122,0.26)', color: '#f0a6ab',
},
}, failed
? 'Nothing was sent and nothing was verified. Close this and try again once everyone is connected.'
: 'If even one member reads a different code, someone is sitting between you. Cancel the group — do not confirm.'),
h('div', { key: 'actions', style: { display: 'flex', gap: '10px' } }, [
h('button', { key: 'c', onClick: onCancel, style: { ...btn(failed), flex: failed ? 2 : 1 } },
failed ? 'Close' : 'Cancel group'),
!failed && h('button', {
key: 'ok', onClick: onConfirm, disabled: waiting,
style: { ...btn(true), flex: 2, opacity: waiting ? 0.4 : 1, cursor: waiting ? 'not-allowed' : 'pointer' },
}, [svg(ICON.shield, { key: 'i' }), 'Everyone sees this code']),
]),
]));
}
// ---------------------------------------------------------------------------
// creating a group
// ---------------------------------------------------------------------------
/**
* Pick members from the 1:1 chats that are already verified.
*
* Only verified sessions are offered. A group built on an unverified session
* would inherit that session's uncertainty and hide it behind a group code that
* looks like it settled the question.
*/
export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel }) {
const [name, setName] = React.useState('');
const [picked, setPicked] = React.useState([]);
const max = GROUP_LIMITS.MAX_MEMBERS - 1; // the creator takes one slot
const toggle = (id) => setPicked((prev) => (
prev.includes(id) ? prev.filter((x) => x !== id)
: prev.length >= max ? prev : [...prev, id]
));
const ready = name.trim().length > 0 && picked.length >= 1;
return h('div', { style: overlay, role: 'dialog', 'aria-modal': 'true' },
h('div', { style: { ...card, maxWidth: '470px' } }, [
h('div', { key: 'h', style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, [
h('span', { key: 'l', style: label }, 'New group'),
h('p', {
key: 'p',
style: { margin: 0, fontSize: '13.5px', lineHeight: 1.6, color: C.ink2 },
}, `Up to ${GROUP_LIMITS.MAX_MEMBERS} people, peer to peer. Everyone will compare one safety code before the group opens.`),
]),
h('input', {
key: 'name',
value: name,
// Clamped by BYTES, because that is the limit the protocol
// enforces. Counting characters here let a Cyrillic name through
// the dialog that the admin's roster signing then rejected.
onChange: (e) => setName(clampToBytes(e.target.value, GROUP_LIMITS.MAX_NAME_BYTES)),
placeholder: 'Group name',
style: {
width: '100%', padding: '12px 14px', borderRadius: '10px', outline: 'none',
background: C.panel2, border: `1px solid ${C.line2}`, color: C.ink,
fontFamily: 'inherit', fontSize: '14.5px',
},
}),
h('div', { key: 'pick', style: { display: 'flex', flexDirection: 'column', gap: '9px' } }, [
h('div', { key: 'l', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' } }, [
h('span', { key: 'a', style: label }, 'Members'),
h('span', { key: 'b', style: { ...label, color: picked.length >= max ? C.warn : C.ink3 } },
`${picked.length} / ${max}`),
]),
candidates.length === 0
? h('div', {
key: 'empty',
style: {
padding: '18px 14px', borderRadius: '10px', textAlign: 'center',
background: C.panel2, border: `1px dashed ${C.line2}`, color: C.ink3, fontSize: '13px', lineHeight: 1.55,
},
}, 'No verified chats yet. Open a 1:1 chat and compare its safety code first — a group is built out of connections you have already checked.')
: h('div', {
key: 'list',
className: 'msc-scroll',
style: { display: 'flex', flexDirection: 'column', gap: '6px', maxHeight: '240px', overflowY: 'auto' },
}, candidates.map((c) => {
const on = picked.includes(c.id);
const full = !on && picked.length >= max;
return h('button', {
key: c.id,
onClick: () => toggle(c.id),
disabled: full,
style: {
display: 'flex', alignItems: 'center', gap: '11px', padding: '10px 12px',
borderRadius: '10px', cursor: full ? 'not-allowed' : 'pointer', textAlign: 'left',
background: on ? 'rgba(240,137,42,0.1)' : 'transparent',
border: `1px solid ${on ? 'rgba(240,137,42,0.32)' : C.line}`,
opacity: full ? 0.4 : 1, fontFamily: 'inherit',
},
}, [
h('span', {
key: 'av',
style: {
flex: 'none', width: '32px', height: '32px', borderRadius: '9px', display: 'grid',
placeItems: 'center', background: C.panel2, border: `1px solid ${C.line}`,
fontFamily: C.mono, fontSize: '11px', fontWeight: 700, color: C.ink2,
},
}, c.mono),
h('span', { key: 'n', style: { flex: 1, minWidth: 0, fontSize: '14px', color: C.ink, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, c.name),
h('span', {
key: 'tick',
style: {
flex: 'none', width: '18px', height: '18px', borderRadius: '5px',
background: on ? C.accent : 'transparent',
border: `1px solid ${on ? C.accent : C.line2}`,
},
}),
]);
})),
]),
// A group is a bigger exposure than a 1:1 chat: without a relay, every
// member's address is visible to every other member, including people
// the user did not personally invite. Say it before they commit, not
// after.
!relayOnly && h('p', {
key: 'ip',
style: {
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: 'rgba(227,179,65,0.08)', border: '1px solid rgba(227,179,65,0.26)', color: '#e3b341',
},
}, 'Relay-only mode is off, so each member connects to you directly and learns your IP address — including members somebody else invited. Turn it on in network settings if that matters here.'),
h('div', { key: 'actions', style: { display: 'flex', gap: '10px' } }, [
h('button', { key: 'c', onClick: onCancel, style: { ...btn(false), flex: 1 } }, 'Cancel'),
h('button', {
key: 'ok',
onClick: () => ready && onCreate({ name: name.trim(), sessionIds: picked }),
disabled: !ready,
style: { ...btn(true), flex: 2, opacity: ready ? 1 : 0.4, cursor: ready ? 'pointer' : 'not-allowed' },
}, 'Create group'),
]),
]));
}
// ---------------------------------------------------------------------------
// something went wrong forming a group
// ---------------------------------------------------------------------------
/**
* Says what failed and what to do about it.
*
* Group formation runs across several links at once, so a failure here is
* usually one dead connection rather than anything the user did wrong. The
* message names the cause; there is nothing to retry automatically, because a
* link that is down will still be down a second later.
*/
export function GroupErrorModal({ message, onDismiss }) {
if (!message) return null;
return h('div', { style: overlay, role: 'alertdialog', 'aria-modal': 'true' },
h('div', { style: { ...card, maxWidth: '400px' } }, [
h('span', { key: 'l', style: label }, 'Group not created'),
h('p', {
key: 'm',
style: { margin: 0, fontSize: '14px', lineHeight: 1.6, color: C.ink2 },
}, message),
h('button', { key: 'ok', onClick: onDismiss, style: btn(true) }, 'Close'),
]));
}
// ---------------------------------------------------------------------------
// adding people to a running group
// ---------------------------------------------------------------------------
/**
* Invite more members into an open group.
*
* Only verified 1:1 chats that are not already in the group are offered, and the
* dialog says plainly what adding somebody costs: a new epoch, and a new code
* that everyone has to compare again. The safety code covers the member set, so
* a changed set means the old code no longer describes who is in the room.
*/
export function AddMembersModal({ candidates, remaining, onAdd, onCancel }) {
const [picked, setPicked] = React.useState([]);
const toggle = (id) => setPicked((prev) => (
prev.includes(id) ? prev.filter((x) => x !== id)
: prev.length >= remaining ? prev : [...prev, id]
));
return h('div', { style: overlay, role: 'dialog', 'aria-modal': 'true' },
h('div', { style: { ...card, maxWidth: '440px' } }, [
h('div', { key: 'h', style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, [
h('span', { key: 'l', style: label }, 'Add members'),
h('p', {
key: 'p',
style: { margin: 0, fontSize: '13.5px', lineHeight: 1.6, color: C.ink2 },
}, remaining > 0
? `Room for ${remaining} more. Everyone will compare a new group code once they join.`
: 'This group is full.'),
]),
candidates.length === 0
? h('div', {
key: 'empty',
style: {
padding: '18px 14px', borderRadius: '10px', textAlign: 'center',
background: C.panel2, border: `1px dashed ${C.line2}`, color: C.ink3, fontSize: '13px', lineHeight: 1.55,
},
}, 'No other verified chats to add. Open a 1:1 chat and compare its safety code first.')
: h('div', {
key: 'list',
className: 'msc-scroll',
style: { display: 'flex', flexDirection: 'column', gap: '6px', maxHeight: '260px', overflowY: 'auto' },
}, candidates.map((c) => {
const on = picked.includes(c.id);
const full = !on && picked.length >= remaining;
return h('button', {
key: c.id,
onClick: () => toggle(c.id),
disabled: full,
style: {
display: 'flex', alignItems: 'center', gap: '11px', padding: '10px 12px',
borderRadius: '10px', cursor: full ? 'not-allowed' : 'pointer', textAlign: 'left',
background: on ? 'rgba(240,137,42,0.1)' : 'transparent',
border: `1px solid ${on ? 'rgba(240,137,42,0.32)' : C.line}`,
opacity: full ? 0.4 : 1, fontFamily: 'inherit',
},
}, [
h('span', {
key: 'av',
style: {
flex: 'none', width: '32px', height: '32px', borderRadius: '9px', display: 'grid',
placeItems: 'center', background: C.panel2, border: `1px solid ${C.line}`,
fontFamily: C.mono, fontSize: '11px', fontWeight: 700, color: C.ink2,
},
}, c.mono),
h('span', { key: 'n', style: { flex: 1, minWidth: 0, fontSize: '14px', color: C.ink, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, c.name),
h('span', {
key: 'tick',
style: {
flex: 'none', width: '18px', height: '18px', borderRadius: '5px',
background: on ? C.accent : 'transparent',
border: `1px solid ${on ? C.accent : C.line2}`,
},
}),
]);
})),
h('p', {
key: 'note',
style: {
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: C.panel2, border: `1px solid ${C.line}`, color: C.ink3,
},
}, 'The group keeps working until they accept. There is no history for them to catch up on — they will only see what is sent from now on.'),
h('div', { key: 'actions', style: { display: 'flex', gap: '10px' } }, [
h('button', { key: 'c', onClick: onCancel, style: { ...btn(false), flex: 1 } }, 'Cancel'),
h('button', {
key: 'ok',
onClick: () => picked.length && onAdd(picked),
disabled: picked.length === 0,
style: { ...btn(true), flex: 2, opacity: picked.length ? 1 : 0.4, cursor: picked.length ? 'pointer' : 'not-allowed' },
}, picked.length > 1 ? `Invite ${picked.length} people` : 'Invite'),
]),
]));
}
// ---------------------------------------------------------------------------
// an inbound invitation
// ---------------------------------------------------------------------------
export function GroupInviteModal({ invite, onAccept, onDecline }) {
if (!invite) return null;
return h('div', { style: overlay, role: 'dialog', 'aria-modal': 'true' },
h('div', { style: card }, [
h('div', { key: 'h', style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, [
h('span', { key: 'l', style: label }, 'Group invitation'),
h('h3', { key: 't', style: { margin: 0, fontSize: '19px', fontWeight: 700, color: C.ink } }, invite.name),
]),
h('p', {
key: 'p',
style: { margin: 0, fontSize: '13.5px', lineHeight: 1.62, color: C.ink2 },
}, [
h('b', { key: 'b', style: { color: C.ink } }, invite.fromLabel),
' invited you to a peer-to-peer group. You will compare one safety code with every member before anything is sent.',
]),
h('p', {
key: 'note',
style: {
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: C.panel2, border: `1px solid ${C.line}`, color: C.ink3,
},
}, 'Other members will learn your presence in this group. There is no message history to catch up on — a group starts empty.'),
h('div', { key: 'actions', style: { display: 'flex', gap: '10px' } }, [
h('button', { key: 'd', onClick: onDecline, style: { ...btn(false), flex: 1 } }, 'Decline'),
h('button', { key: 'a', onClick: onAccept, style: { ...btn(true), flex: 2 } }, 'Join group'),
]),
]));
}
// ---------------------------------------------------------------------------
// the conversation
// ---------------------------------------------------------------------------
function MemberStrip({ group, onRemove, isAdmin }) {
return h('div', {
className: 'msc-scroll',
style: {
display: 'flex', gap: '7px', padding: '9px 16px', overflowX: 'auto',
borderBottom: `1px solid ${C.line}`, flex: 'none',
},
}, group.members.map((m) => {
const self = m.state === MEMBER_STATE.SELF;
const lost = m.state === MEMBER_STATE.LOST;
const dot = self || m.state === MEMBER_STATE.LINKED ? C.good
: m.state === MEMBER_STATE.PENDING ? C.warn : C.bad;
const via = m.state === MEMBER_STATE.PENDING;
return h('span', {
key: m.fp,
title: self ? 'You'
: m.state === MEMBER_STATE.LINKED ? 'Direct peer-to-peer link'
: m.state === MEMBER_STATE.PENDING ? 'No direct link yet — messages are relayed by another member while one is being built'
: `${m.name} is offline and will not receive messages. They are still a member — removing them re-keys the group.`,
style: {
flex: 'none', display: 'inline-flex', alignItems: 'center', gap: '6px',
padding: '5px 10px', borderRadius: '20px',
// A member who is offline should not read as one who is present.
// They stay listed because membership is a signed, epoch-ordered
// fact that a dropped connection does not change but the chip
// says plainly that nothing sent now reaches them.
background: lost ? 'transparent' : C.panel2,
border: `1px solid ${lost ? 'rgba(229,114,122,0.3)' : C.line}`,
fontSize: '12.5px', color: self ? C.ink : (lost ? C.ink3 : C.ink2),
opacity: lost ? 0.75 : 1,
},
}, [
h('span', { key: 'd', style: { width: '7px', height: '7px', borderRadius: '50%', background: dot } }),
h('span', { key: 'n', style: lost ? { textDecoration: 'line-through' } : undefined }, self ? 'You' : m.name),
lost && h('span', {
key: 'off',
style: { fontFamily: C.mono, fontSize: '10px', color: C.bad, letterSpacing: '0.04em' },
}, 'offline'),
via && svg(ICON.relay, { key: 'r', color: C.warn }),
(isAdmin && !self && onRemove) && h('button', {
key: 'x',
onClick: () => onRemove(m.fp),
title: `Remove ${m.name}`,
style: { border: 'none', background: 'transparent', color: C.ink3, cursor: 'pointer', display: 'grid', padding: 0 },
dangerouslySetInnerHTML: { __html: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>' },
}),
]);
}));
}
function Bubble({ msg }) {
const mine = msg.type === 'sent';
const system = msg.type === 'system';
if (system) {
return h('div', {
style: {
alignSelf: 'center', maxWidth: '80%', textAlign: 'center', padding: '6px 12px',
borderRadius: '9px', background: C.panel2, border: `1px solid ${C.line}`,
fontSize: '12px', color: C.ink3, lineHeight: 1.5,
},
}, msg.message);
}
return h('div', {
style: {
alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: 'min(74%, 560px)',
display: 'flex', flexDirection: 'column', gap: '3px',
},
}, [
!mine && h('span', {
key: 'who',
style: { fontSize: '11.5px', fontWeight: 600, color: C.accent, paddingLeft: '3px' },
}, msg.senderName || 'Member'),
h('div', {
key: 'b',
style: {
padding: '9px 13px', borderRadius: mine ? '13px 13px 4px 13px' : '13px 13px 13px 4px',
background: mine ? 'rgba(240,137,42,0.14)' : C.panel2,
border: `1px solid ${mine ? 'rgba(240,137,42,0.26)' : C.line}`,
color: C.ink, fontSize: '14.5px', lineHeight: 1.5, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
},
}, msg.message),
h('span', {
key: 't',
style: { fontFamily: C.mono, fontSize: '10px', color: C.ink3, alignSelf: mine ? 'flex-end' : 'flex-start', padding: '0 3px' },
}, [
new Date(msg.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
msg.relayed ? ' · relayed' : '',
].join('')),
]);
}
export function GroupChatView({
group, input, setInput, onSend, onLeave, onRemoveMember, onAddMembers, isAdmin, scrollRef,
}) {
const ready = group.phase === GROUP_PHASE.READY && group.sasConfirmed;
// Only members we are RELAYING to. A member who is offline is not relayed
// they are unreachable, which the member strip already says in its own
// words and counting them here put a notice about relaying on screen for
// a situation where nothing is being relayed at all.
const degraded = group.members.some((m) => m.state === MEMBER_STATE.PENDING);
const submit = (e) => {
e.preventDefault();
if (!ready || !input.trim()) return;
onSend(input);
};
return h('div', {
style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, background: C.bg },
}, [
// header
h('div', {
key: 'head',
style: {
flex: 'none', display: 'flex', alignItems: 'center', gap: '12px', padding: '0 16px',
height: '64px', borderBottom: `1px solid ${C.line}`,
},
}, [
h('span', {
key: 'av',
style: {
flex: 'none', width: '38px', height: '38px', borderRadius: '11px', display: 'grid',
placeItems: 'center', background: 'rgba(240,137,42,0.12)',
border: '1px solid rgba(240,137,42,0.24)', color: C.accent,
fontFamily: C.mono, fontSize: '12px', fontWeight: 700,
},
}, groupInitials(group.name)),
h('div', { key: 'meta', style: { flex: 1, minWidth: 0 } }, [
h('div', {
key: 'n',
style: { fontSize: '15px', fontWeight: 700, color: C.ink, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}, group.name),
h('div', {
key: 's',
style: { fontSize: '11.5px', color: degraded ? C.warn : C.ink3, display: 'flex', alignItems: 'center', gap: '5px' },
}, [
svg(ICON.users, { key: 'i', width: '13px', height: '13px' }),
`${group.members.length} members`,
ready && group.sasCode ? ` · code ${group.sasCode}` : '',
]),
]),
(isAdmin && onAddMembers && group.members.length < GROUP_LIMITS.MAX_MEMBERS) && h('button', {
key: 'add', onClick: onAddMembers, title: 'Invite more members',
style: { ...btn(false), padding: '8px 12px', fontSize: '12.5px' },
}, [svg(ICON.plus, { key: 'i' }), 'Add']),
h('button', {
key: 'leave', onClick: onLeave, title: 'Leave this group',
style: { ...btn(false), padding: '8px 12px', fontSize: '12.5px', color: C.bad, borderColor: 'rgba(229,114,122,0.3)' },
}, 'Leave'),
]),
h(MemberStrip, { key: 'strip', group, onRemove: onRemoveMember, isAdmin }),
degraded && ready && h('div', {
key: 'relay-note',
style: {
flex: 'none', padding: '8px 16px', fontSize: '12px', lineHeight: 1.5, color: C.warn,
background: 'rgba(227,179,65,0.08)', borderBottom: `1px solid ${C.line}`,
},
}, 'Some members have no direct link to you yet. Their messages travel through another member, who can see that you are talking but cannot read past the signature or change what you said. The group keeps trying to connect them directly.'),
// transcript
h('div', {
key: 'msgs',
ref: scrollRef,
className: 'msc-scroll',
style: {
flex: 1, minHeight: 0, overflowY: 'auto', padding: '18px 16px',
display: 'flex', flexDirection: 'column', gap: '11px',
},
}, group.messages.length === 0
? [h('div', {
key: 'empty',
style: { margin: 'auto', textAlign: 'center', color: C.ink3, fontSize: '13.5px', lineHeight: 1.6, maxWidth: '320px' },
}, ready
? 'Nothing here yet. Messages are signed by their sender and travel over each members own encrypted link.'
: 'Compare the group code with every member to open this group.')]
: group.messages.map((m) => h(Bubble, { key: m.id, msg: m }))),
// composer
h('form', {
key: 'composer',
onSubmit: submit,
style: {
flex: 'none', display: 'flex', gap: '9px', padding: '12px 16px',
borderTop: `1px solid ${C.line}`, alignItems: 'flex-end',
},
}, [
h('input', {
key: 'in',
value: input,
onChange: (e) => setInput(e.target.value),
placeholder: ready ? `Message ${group.name}` : 'Confirm the group code first',
disabled: !ready,
maxLength: GROUP_LIMITS.MAX_BODY_BYTES,
style: {
flex: 1, minWidth: 0, padding: '12px 14px', borderRadius: '11px', outline: 'none',
background: C.panel2, border: `1px solid ${C.line2}`, color: C.ink,
fontFamily: 'inherit', fontSize: '14.5px', opacity: ready ? 1 : 0.5,
},
}),
h('button', {
key: 'send', type: 'submit', disabled: !ready || !input.trim(), title: 'Send',
style: {
...btn(true), flex: 'none', width: '44px', height: '44px', padding: 0, borderRadius: '11px',
opacity: (!ready || !input.trim()) ? 0.4 : 1,
},
}, svg(ICON.send)),
]),
]);
}
+5 -5
View File
@@ -33,14 +33,14 @@ function Roadmap() {
features: ["ECDH + DTLS + SAS triple-layer security", "ECDH P-384 + AES-GCM 256-bit encryption", "DTLS fingerprint verification", "SAS (Short Authentication String) verification", "Perfect Forward Secrecy with key rotation", "Enhanced MITM attack prevention", "Complete ASN.1 DER validation", "OID and EC point verification", "SPKI structure validation", "P2P WebRTC architecture", "Metadata protection", "100% open source code"] },
{ v: "v5.0", title: "Desktop Edition", sub: "Native desktop apps for Windows, macOS, and Linux", status: "released", date: "Early 2026",
features: ["Windows desktop app (Tauri v2)", "macOS desktop app (Tauri v2)", "Linux AppImage support (Tauri v2)", "Real-time notifications", "Automatic reconnection", "Cross-device synchronization", "Improved UX/UI", "Support for files up to 100MB"] },
{ v: "v5.5", title: "Secure Voice & Calls", sub: "Encrypted voice messages, audio calls, and video calls", status: "current", date: "Now",
{ v: "v5.5", title: "Secure Voice & Calls", sub: "Encrypted voice messages, audio calls, and video calls", status: "released", date: "Early 2026",
features: ["End-to-end encrypted voice messages", "1:1 encrypted audio calls (WebRTC)", "1:1 encrypted video calls (WebRTC)", "Perfect Forward Secrecy for live media", "SRTP/DTLS-protected media streams", "In-call SAS verification", "Call notifications and auto-reconnection", "Low-latency P2P media"] },
{ v: "v6.0", title: "Mobile Edition", sub: "Native mobile apps for iOS and Android", status: "dev", date: "Q4 2026",
{ v: "v6.0", title: "Group Communications", sub: "Group chats with preserved privacy", status: "current", date: "Now",
features: ["P2P group chats up to 8 participants", "Mesh delivery with signed relay fallback", "One group safety code, compared by everyone", "Commit-then-reveal ceremony against code grinding", "Per-group identity keys, ephemeral by design", "Signed membership with epoch ordering", "Signed messages, so a split transcript is provable", "No server, no shared group key, no history"] },
{ v: "v6.5", title: "Mobile Edition", sub: "Native mobile apps for iOS and Android", status: "dev", date: "Q2 2027",
features: ["iOS native app (Swift/SwiftUI)", "Android native app (Kotlin/Jetpack Compose)", "PWA support for mobile browsers", "Real-time push notifications", "Battery optimization", "Mobile-optimized UX/UI", "Offline message queuing", "Biometric authentication"] },
{ v: "v6.5", title: "Quantum-Resistant Edition", sub: "Protection against quantum computers", status: "planned", date: "Q2 2027",
{ v: "v7.0", title: "Quantum-Resistant Edition", sub: "Protection against quantum computers", status: "planned", date: "Q4 2027",
features: ["Post-quantum cryptography CRYSTALS-Kyber", "SPHINCS+ digital signatures", "Hybrid scheme: classic + PQ", "Quantum-safe key exchange", "Updated hashing algorithms", "Migration of existing sessions", "Compatibility with v5.x", "Quantum-resistant protocols"] },
{ v: "v7.0", title: "Group Communications", sub: "Group chats with preserved privacy", status: "planned", date: "Q4 2027",
features: ["P2P group connections up to 8 participants", "Mesh networking for groups", "Signal Double Ratchet for groups", "Anonymous groups without metadata", "Ephemeral groups (disappear after session)", "Cryptographic group administration", "Group member auditing"] },
{ v: "v7.5", title: "Decentralized Network", sub: "Fully decentralized network", status: "research", date: "2028",
features: ["Node mesh network", "DHT for peer discovery", "Built-in onion routing", "Tokenomics and node incentives", "Governance via DAO", "Interoperability with other networks", "Cross-platform compatibility", "Self-healing network"] },
{ v: "v8.0", title: "AI Privacy Assistant", sub: "AI for privacy and security", status: "research", date: "2028+",
File diff suppressed because it is too large Load Diff
+767
View File
@@ -0,0 +1,767 @@
// Group cryptography for SecureBit.chat.
//
// WHY A SEPARATE IDENTITY KEY EXISTS
// ----------------------------------
// The pairwise handshake generates a fresh ECDSA key pair per CONNECTION
// (see createSecureOffer / createSecureAnswer). That is exactly right for 1:1 —
// there are no accounts, so there is nothing a long-term key should outlive —
// but it means Alice presents a different identity key to Bob than she presents
// to Carol. A group cannot be built on that: a membership operation signed
// toward Bob would be unverifiable by Carol, and there would be nothing stable
// to put in a group safety code.
//
// So a group gets its own ECDSA P-384 key pair, generated per group per device
// and destroyed with the group. It never touches the pairwise handshake, and it
// is published to the other members over the ALREADY VERIFIED pairwise channels.
// That keeps the 1:1 protocol untouched while giving the group one signing key
// per member for the epoch's lifetime.
//
// WHY THE SAFETY CODE IS COMMIT-THEN-REVEAL
// -----------------------------------------
// The obvious construction — hash the sorted set of member key fingerprints and
// show the digits, the way a Signal safety number works — is unsafe at the
// length a group can actually read aloud.
//
// The attacker here is a group member who introduces two others and sits in the
// middle of the pair they could not reach directly. They present key K_b to Bob
// and K_c to Carol. To go unnoticed they need Bob's digits and Carol's digits to
// match, and both sets are under their control: they can generate candidate key
// pairs until the two truncated hashes collide. That is a BIRTHDAY search, not a
// preimage search — roughly 10^(d/2) work for d digits. A 7-digit code falls in
// a few thousand tries. Signal answers this by making the safety number 60
// digits; nobody reads 60 digits aloud to seven other people.
//
// Commit-then-reveal removes the search instead of outrunning it. Every member
// commits to a secret nonce (publishing only its hash), and only once ALL
// commitments are in does anyone reveal. The attacker must fix both of their
// commitments before they can see a single honest nonce, so they cannot steer
// either digit string — they are reduced to guessing, once, at 10^-d. Seven
// digits is then genuinely safe, and it matches the pairwise SAS the users have
// already been trained to compare.
//
// The ordering is the whole security property: revealing before every commitment
// has arrived hands the attacker exactly the grinding freedom this construction
// exists to deny. GroupSasCeremony below enforces that transition; nothing else
// may.
//
// WHY GROUP MESSAGES ARE SIGNED
// -----------------------------
// Messages fan out over N-1 independent pairwise ratchets, so each recipient
// authenticates only that the sender's SESSION sent it. A malicious member could
// send different text to different people under one sequence number and no
// recipient could tell. A signature over (group, epoch, seq, body hash) with the
// sender's group identity key makes such a split provable: two valid signatures
// from one member on one seq are non-repudiable evidence. It does not prevent
// the split — nothing without a shared transcript can — it makes it detectable,
// which is what a group without a server can honestly offer.
//
// This module is pure: SubtleCrypto is injected, no DOM, no network, no state
// beyond the ceremony object. It parses attacker-controlled input, so every
// length and range is checked before the value is used.
export const GROUP_LIMITS = Object.freeze({
// Eight is a mesh limit, not a crypto limit: it is where N(N-1)/2 pairwise
// connections and N-1 fan-out copies stop being comfortable in a browser.
MAX_MEMBERS: 8,
MIN_MEMBERS: 2,
GROUP_ID_BYTES: 16,
NONCE_BYTES: 32,
COMMIT_BYTES: 32,
FINGERPRINT_BYTES: 32,
// Matches the pairwise SAS. Safe at this length only because of the
// commit-reveal ordering above — see the header.
SAS_DIGITS: 7,
// Bytes, not characters — and the gap between the two is a real trap. The
// create dialog used to cap input at 64 CHARACTERS, so a 36-character
// Cyrillic name ("Наша секретная группа для обсуждений") is 68 bytes and was
// accepted by the UI and then rejected here, inside the admin's roster
// signing, killing group formation with no visible cause. The dialog now
// clamps by bytes, and the budget is generous enough that a normal name in
// any script fits.
MAX_NAME_BYTES: 128,
// Epoch is a uint32 on the wire; a group that changes membership four
// billion times has other problems.
MAX_EPOCH: 0xffffffff,
MAX_SPKI_BYTES: 256,
MIN_SPKI_BYTES: 40,
MAX_SIG_BYTES: 160,
MIN_SIG_BYTES: 48,
/**
* Group frames travel as chat content on a pairwise session, and that path
* ends in EnhancedSecureCryptoUtils.sanitizeMessage, which runs DOMPurify and
* then truncates to 2000 characters. Truncation would corrupt a frame
* silently, so every frame has to fit underneath it after base64 see
* FRAME_BUDGET_CHARS and the envelope in GroupSession.
*
* A frame's fixed overhead (group id, epoch, sequence, sender fingerprint,
* timestamp, signature, envelope) is roughly 300 bytes, and base64 costs
* another third. 1024 bytes of body leaves comfortable headroom, and it is
* bytes rather than characters so a message in a non-Latin script is bounded
* by the same real budget.
*/
MAX_BODY_BYTES: 1024,
FRAME_BUDGET_CHARS: 1800,
/**
* A mesh descriptor as it travels inside a group frame.
*
* SBQ2 caps a descriptor payload at 512 bytes (LIMITS.MAX_PAYLOAD_BYTES),
* which is "SB2:" plus 683 base64url characters at the absolute worst. 768
* bounds the allocation with room to spare and still leaves the whole frame
* descriptor, two fingerprints, a nonce and a signature, wrapped in a
* relay envelope and base64'd under FRAME_BUDGET_CHARS. A descriptor that
* somehow does not fit is refused rather than truncated; the pair simply
* stays on the relay path, which is the same thing that happens when the
* mesh dial fails for any other reason.
*/
MAX_DESCRIPTOR_CHARS: 768,
/** Binds an answer to the one dial attempt that asked for it. */
MESH_NONCE_BYTES: 16,
});
/** Which half of a mesh dial a signature covers. */
export const MESH_KINDS = Object.freeze({ OFFER: 'moffer', ANSWER: 'manswer' });
export const MEMBER_OPS = Object.freeze({
CREATE: 'create',
ADD: 'add',
REMOVE: 'remove',
RENAME: 'rename',
});
const ENC = new TextEncoder();
class GroupCryptoError extends Error {
constructor(message, code = 'group_crypto') {
super(message);
this.name = 'GroupCryptoError';
this.code = code;
}
}
const fail = (msg, code) => { throw new GroupCryptoError(msg, code); };
// ---------------------------------------------------------------------------
// codecs
// ---------------------------------------------------------------------------
export function toHex(bytes) {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let out = '';
for (let i = 0; i < view.length; i++) out += view[i].toString(16).padStart(2, '0');
return out;
}
export function fromHex(hex) {
if (typeof hex !== 'string' || hex.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(hex)) {
fail('not a hex string', 'bad_hex');
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
return out;
}
export function toB64(bytes) {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let binary = '';
for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
return btoa(binary);
}
export function fromB64(b64, { max = GROUP_LIMITS.MAX_SPKI_BYTES } = {}) {
if (typeof b64 !== 'string') fail('not a base64 string', 'bad_b64');
// Bound BEFORE decoding: base64 expands 3:4, so this caps the allocation.
if (b64.length > Math.ceil((max * 4) / 3) + 4) fail('base64 payload exceeds its limit', 'bad_b64');
let binary;
try {
binary = atob(b64);
} catch (_) {
fail('malformed base64', 'bad_b64');
}
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
export function randomBytes(n) {
return crypto.getRandomValues(new Uint8Array(n));
}
/** A fresh group id. Shared between members, unlike the local-only sessionId. */
export function newGroupId() {
return toHex(randomBytes(GROUP_LIMITS.GROUP_ID_BYTES));
}
// ---------------------------------------------------------------------------
// canonical encoding
// ---------------------------------------------------------------------------
/**
* Length-prefixed concatenation.
*
* Everything signed or hashed in this module goes through here, so that no two
* distinct field sets can ever produce the same bytes. Plain concatenation would
* let ("ab","c") and ("a","bc") sign the same payload, which is precisely how a
* membership operation gets reinterpreted as a different one.
*/
function lp(label, ...parts) {
const chunks = [ENC.encode(label + '\0')];
let total = chunks[0].length;
for (const part of parts) {
const bytes = part instanceof Uint8Array ? part
: typeof part === 'string' ? ENC.encode(part)
: fail('unsupported payload component', 'bad_payload');
const header = new Uint8Array(4);
new DataView(header.buffer).setUint32(0, bytes.length);
chunks.push(header, bytes);
total += 4 + bytes.length;
}
const out = new Uint8Array(total);
let o = 0;
for (const c of chunks) { out.set(c, o); o += c.length; }
return out;
}
function u32(n) {
if (!Number.isInteger(n) || n < 0 || n > GROUP_LIMITS.MAX_EPOCH) fail('value out of uint32 range', 'bad_u32');
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n);
return b;
}
/** Constant-time byte comparison. Cheap, and keeps the habit uniform. */
function equalBytes(a, b) {
if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array) || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
// ---------------------------------------------------------------------------
// validation of attacker-supplied values
// ---------------------------------------------------------------------------
export function assertGroupId(groupId) {
if (typeof groupId !== 'string' || groupId.length !== GROUP_LIMITS.GROUP_ID_BYTES * 2 || !/^[0-9a-f]+$/.test(groupId)) {
fail('malformed group id', 'bad_group_id');
}
return groupId;
}
export function assertFingerprint(fp) {
if (typeof fp !== 'string' || fp.length !== GROUP_LIMITS.FINGERPRINT_BYTES * 2 || !/^[0-9a-f]+$/.test(fp)) {
fail('malformed member fingerprint', 'bad_fingerprint');
}
return fp;
}
export function assertEpoch(epoch) {
if (!Number.isInteger(epoch) || epoch < 0 || epoch > GROUP_LIMITS.MAX_EPOCH) {
fail('epoch out of range', 'bad_epoch');
}
return epoch;
}
export function assertName(name) {
const value = typeof name === 'string' ? name : '';
if (ENC.encode(value).length > GROUP_LIMITS.MAX_NAME_BYTES) fail('group name too long', 'bad_name');
return value;
}
/**
* Canonical member ordering.
*
* Sorting by fingerprint never by join order, never by however the array
* arrived is what makes every device hash identical bytes. A set that two
* members order differently produces two different safety codes and the group
* fails to form for no visible reason.
*/
export function canonicalFingerprints(fps) {
if (!Array.isArray(fps)) fail('member list is not an array', 'bad_members');
if (fps.length < GROUP_LIMITS.MIN_MEMBERS) fail('a group needs at least two members', 'bad_members');
if (fps.length > GROUP_LIMITS.MAX_MEMBERS) fail(`a group is limited to ${GROUP_LIMITS.MAX_MEMBERS} members`, 'too_many_members');
const seen = new Set();
for (const fp of fps) {
assertFingerprint(fp);
if (seen.has(fp)) fail('duplicate member fingerprint', 'duplicate_member');
seen.add(fp);
}
return [...fps].sort();
}
// ---------------------------------------------------------------------------
// group identity key
// ---------------------------------------------------------------------------
/**
* A group identity key pair for this device, in this group.
*
* Non-extractable private key: it signs and nothing else, and it must not be
* reachable from a heap dump the way an exportable key is. The public half is
* exported once, here, because it has to travel to the other members.
*/
export async function generateGroupIdentity(subtle) {
const keyPair = await subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-384' },
false,
['sign', 'verify'],
);
const spki = new Uint8Array(await subtle.exportKey('spki', keyPair.publicKey));
const fingerprint = await fingerprintSpki(subtle, spki);
return { keyPair, spki, fingerprint };
}
/** SHA-256 over the SPKI, hex. The stable name of a member inside a group. */
export async function fingerprintSpki(subtle, spki) {
if (!(spki instanceof Uint8Array) || spki.length < GROUP_LIMITS.MIN_SPKI_BYTES || spki.length > GROUP_LIMITS.MAX_SPKI_BYTES) {
fail('SPKI length out of range', 'bad_spki');
}
return toHex(new Uint8Array(await subtle.digest('SHA-256', spki)));
}
/**
* Import a member's published verifying key.
*
* Returns the key AND the fingerprint computed from the bytes we were actually
* given, never one the sender asserted. A member is identified by what their key
* hashes to; accepting a claimed fingerprint would let a member occupy someone
* else's slot in the safety code.
*/
export async function importMemberIdentity(subtle, spki) {
const fingerprint = await fingerprintSpki(subtle, spki);
let publicKey;
try {
publicKey = await subtle.importKey('spki', spki, { name: 'ECDSA', namedCurve: 'P-384' }, false, ['verify']);
} catch (_) {
fail('member identity key is not a valid P-384 public key', 'bad_spki');
}
return { publicKey, fingerprint };
}
// ---------------------------------------------------------------------------
// commit / reveal
// ---------------------------------------------------------------------------
/**
* Commitment to a member's nonce for one epoch.
*
* The group id and epoch are inside the hash so a commitment cannot be replayed
* into a different group or a later epoch, and the fingerprint is inside so one
* member cannot claim another member's commitment as their own.
*/
export async function buildCommitment(subtle, { groupId, epoch, fingerprint, nonce }) {
assertGroupId(groupId);
assertEpoch(epoch);
assertFingerprint(fingerprint);
if (!(nonce instanceof Uint8Array) || nonce.length !== GROUP_LIMITS.NONCE_BYTES) {
fail('nonce must be 32 bytes', 'bad_nonce');
}
const payload = lp('securebit/group/commit/v1', fromHex(groupId), u32(epoch), fromHex(fingerprint), nonce);
return new Uint8Array(await subtle.digest('SHA-256', payload));
}
export async function verifyCommitment(subtle, commitment, fields) {
if (!(commitment instanceof Uint8Array) || commitment.length !== GROUP_LIMITS.COMMIT_BYTES) return false;
let expected;
try {
expected = await buildCommitment(subtle, fields);
} catch (_) {
return false;
}
return equalBytes(commitment, expected);
}
/**
* The digits every member reads aloud.
*
* Inputs are the full member set with their revealed nonces, sorted by
* fingerprint. Every member's key AND every member's nonce is covered, so a
* substituted key or a substituted nonce anywhere in the group changes the code
* for the members who received the substitution and not for the others, which
* is the mismatch the humans are there to notice.
*/
export async function computeGroupSas(subtle, { groupId, epoch, contributions, digits = GROUP_LIMITS.SAS_DIGITS }) {
assertGroupId(groupId);
assertEpoch(epoch);
if (!Array.isArray(contributions)) fail('contributions must be an array', 'bad_contributions');
if (!Number.isInteger(digits) || digits < 4 || digits > 12) fail('digit count out of range', 'bad_digits');
canonicalFingerprints(contributions.map((c) => c && c.fingerprint));
const ordered = [...contributions].sort((a, b) => (a.fingerprint < b.fingerprint ? -1 : 1));
const parts = [];
for (const c of ordered) {
if (!(c.nonce instanceof Uint8Array) || c.nonce.length !== GROUP_LIMITS.NONCE_BYTES) {
fail('every member must contribute a 32-byte nonce', 'bad_nonce');
}
parts.push(fromHex(c.fingerprint), c.nonce);
}
const ikm = lp('securebit/group/sas/v1', fromHex(groupId), u32(epoch), ...parts);
const salt = new Uint8Array(await subtle.digest('SHA-256', lp('securebit/group/sas-salt/v1', fromHex(groupId), u32(epoch))));
let key = null;
try {
key = await subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
const bits = await subtle.deriveBits(
{ name: 'HKDF', hash: 'SHA-256', salt, info: ENC.encode('securebit-group-sas-v1') },
key, 64,
);
const dv = new DataView(bits);
// 52 bits of entropy folded into the digits. Staying under 2^53 keeps
// this exact in a JS Number; the modulo bias at 10^7 is ~1e-9.
const n = dv.getUint32(0) * 2 ** 20 + (dv.getUint32(4) >>> 12);
return String(n % 10 ** digits).padStart(digits, '0');
} finally {
try { ikm.fill(0); } catch (_) { /* not ours to wipe */ }
}
}
/**
* The commit-reveal state machine.
*
* This object exists so that the ordering rule has exactly one implementation.
* `reveal()` throws until every expected commitment has arrived, and that refusal
* is the entire security argument for a 7-digit group code see the header.
*/
export class GroupSasCeremony {
constructor({ groupId, epoch, selfFingerprint, memberFingerprints }) {
this.groupId = assertGroupId(groupId);
this.epoch = assertEpoch(epoch);
this.selfFingerprint = assertFingerprint(selfFingerprint);
this.members = canonicalFingerprints(memberFingerprints);
if (!this.members.includes(this.selfFingerprint)) {
fail('the local member is not in the member set', 'not_a_member');
}
this.nonce = randomBytes(GROUP_LIMITS.NONCE_BYTES);
this.commitments = new Map(); // fp -> Uint8Array(32)
this.nonces = new Map(); // fp -> Uint8Array(32)
this.revealed = false;
this.code = null;
}
/** Our own commitment, to be broadcast first. */
async ownCommitment(subtle) {
const commitment = await buildCommitment(subtle, {
groupId: this.groupId, epoch: this.epoch,
fingerprint: this.selfFingerprint, nonce: this.nonce,
});
this.commitments.set(this.selfFingerprint, commitment);
return commitment;
}
/**
* Record a peer commitment. Rejects anyone outside the member set, and
* refuses to overwrite one already recorded a second, different commitment
* from the same member is an attempt to move after seeing more of the round.
*/
acceptCommitment(fingerprint, commitment) {
assertFingerprint(fingerprint);
if (!this.members.includes(fingerprint)) fail('commitment from a non-member', 'not_a_member');
if (!(commitment instanceof Uint8Array) || commitment.length !== GROUP_LIMITS.COMMIT_BYTES) {
fail('malformed commitment', 'bad_commitment');
}
const existing = this.commitments.get(fingerprint);
if (existing) {
if (!equalBytes(existing, commitment)) fail('member changed their commitment', 'commitment_changed');
return false;
}
this.commitments.set(fingerprint, commitment);
return true;
}
get commitmentsComplete() {
return this.members.every((fp) => this.commitments.has(fp));
}
/**
* Our nonce available ONLY once every commitment is in.
*
* This is the gate the whole construction rests on. Do not add a caller that
* bypasses it, and do not "helpfully" relax it when a member is slow: a
* timeout must fail the ceremony, never proceed without a commitment.
*/
reveal() {
if (!this.commitmentsComplete) {
fail('cannot reveal before every member has committed', 'premature_reveal');
}
this.revealed = true;
this.nonces.set(this.selfFingerprint, this.nonce);
return this.nonce;
}
/** Record a peer nonce, checking it against the commitment they are bound to. */
async acceptReveal(subtle, fingerprint, nonce) {
assertFingerprint(fingerprint);
if (!this.members.includes(fingerprint)) fail('reveal from a non-member', 'not_a_member');
const commitment = this.commitments.get(fingerprint);
if (!commitment) fail('reveal arrived before the commitment', 'reveal_without_commitment');
const ok = await verifyCommitment(subtle, commitment, {
groupId: this.groupId, epoch: this.epoch, fingerprint, nonce,
});
if (!ok) fail('revealed nonce does not match the commitment', 'commitment_mismatch');
this.nonces.set(fingerprint, nonce);
return true;
}
get revealsComplete() {
return this.members.every((fp) => this.nonces.has(fp));
}
/** The digits, once every nonce is in and verified. */
async finish(subtle) {
if (!this.revealsComplete) fail('not every member has revealed', 'incomplete_reveal');
this.code = await computeGroupSas(subtle, {
groupId: this.groupId,
epoch: this.epoch,
contributions: this.members.map((fp) => ({ fingerprint: fp, nonce: this.nonces.get(fp) })),
});
return this.code;
}
/** Wipe the nonce material once the code exists or the ceremony is abandoned. */
destroy() {
try { this.nonce.fill(0); } catch (_) {}
for (const n of this.nonces.values()) { try { n.fill(0); } catch (_) {} }
this.nonces.clear();
this.commitments.clear();
}
}
// ---------------------------------------------------------------------------
// membership operations
// ---------------------------------------------------------------------------
/**
* The bytes a membership change is signed over.
*
* The resulting member set is signed in full rather than the delta, so a
* recipient never has to reconstruct state from a sequence of operations it may
* have received out of order or incompletely. The epoch is what orders them, and
* accepting only a strictly greater epoch is what refuses both a replay and a
* rollback to a set that used to be valid.
*/
export function memberOpPayload({ groupId, epoch, op, memberFps, name = '' }) {
assertGroupId(groupId);
assertEpoch(epoch);
if (!Object.values(MEMBER_OPS).includes(op)) fail('unknown membership operation', 'bad_op');
const ordered = canonicalFingerprints(memberFps);
return lp(
'securebit/group/member-op/v1',
fromHex(groupId), u32(epoch), op, assertName(name),
...ordered.map((fp) => fromHex(fp)),
);
}
export async function signMemberOp(subtle, privateKey, fields) {
const sig = await subtle.sign({ name: 'ECDSA', hash: 'SHA-384' }, privateKey, memberOpPayload(fields));
return new Uint8Array(sig);
}
export async function verifyMemberOp(subtle, publicKey, fields, signature) {
if (!(signature instanceof Uint8Array)
|| signature.length < GROUP_LIMITS.MIN_SIG_BYTES
|| signature.length > GROUP_LIMITS.MAX_SIG_BYTES) {
return false;
}
let payload;
try {
payload = memberOpPayload(fields);
} catch (_) {
return false;
}
try {
return await subtle.verify({ name: 'ECDSA', hash: 'SHA-384' }, publicKey, signature, payload);
} catch (_) {
return false;
}
}
// ---------------------------------------------------------------------------
// group messages
// ---------------------------------------------------------------------------
export async function hashBody(subtle, body) {
const bytes = typeof body === 'string' ? ENC.encode(body) : body;
if (!(bytes instanceof Uint8Array)) fail('message body must be a string or bytes', 'bad_body');
if (bytes.length > GROUP_LIMITS.MAX_BODY_BYTES) fail('message body exceeds the group limit', 'body_too_large');
return new Uint8Array(await subtle.digest('SHA-256', bytes));
}
/**
* The bytes a group message is signed over.
*
* Only the hash of the body is signed, not the body: it keeps the payload a
* fixed size regardless of message length, and the hash is what a later
* consistency comparison needs anyway.
*/
export function groupMessagePayload({ groupId, epoch, seq, senderFp, bodyHash }) {
assertGroupId(groupId);
assertEpoch(epoch);
assertEpoch(seq); // same uint32 range; a per-sender counter
assertFingerprint(senderFp);
if (!(bodyHash instanceof Uint8Array) || bodyHash.length !== 32) fail('body hash must be 32 bytes', 'bad_body_hash');
return lp('securebit/group/message/v1', fromHex(groupId), u32(epoch), u32(seq), fromHex(senderFp), bodyHash);
}
export async function signGroupMessage(subtle, privateKey, fields) {
const sig = await subtle.sign({ name: 'ECDSA', hash: 'SHA-384' }, privateKey, groupMessagePayload(fields));
return new Uint8Array(sig);
}
export async function verifyGroupMessage(subtle, publicKey, fields, signature) {
if (!(signature instanceof Uint8Array)
|| signature.length < GROUP_LIMITS.MIN_SIG_BYTES
|| signature.length > GROUP_LIMITS.MAX_SIG_BYTES) {
return false;
}
let payload;
try {
payload = groupMessagePayload(fields);
} catch (_) {
return false;
}
try {
return await subtle.verify({ name: 'ECDSA', hash: 'SHA-384' }, publicKey, signature, payload);
} catch (_) {
return false;
}
}
// ---------------------------------------------------------------------------
// mesh links
// ---------------------------------------------------------------------------
//
// WHY A MESH DESCRIPTOR IS SIGNED WITH THE GROUP IDENTITY KEY
// -----------------------------------------------------------
// Two members who have never met have no pairwise channel to introduce
// themselves over, so their WebRTC descriptors have to travel through a member
// who CAN reach both — in practice the admin. That relay is not trusted with
// the content of the group, and it must not become trusted with the shape of
// the group's transport either: a relay that could swap a descriptor for its
// own would sit in the middle of the very link that was built to route around
// it.
//
// The descriptor is therefore signed with the sender's group identity key —
// the same key whose fingerprint the signed roster names and whose presence the
// humans confirmed when they compared the group code. A relay can drop a dial
// or delay it, which costs availability and nothing else. It cannot substitute
// one, because it cannot produce that signature.
//
// The signature covers the direction (offer or answer), BOTH fingerprints and a
// per-attempt nonce as well as the descriptor bytes:
//
// - the direction stops an offer being replayed back as an answer;
// - both fingerprints stop a descriptor addressed to one member being
// re-aimed at another;
// - the nonce binds an answer to the one dial that asked for it, so an answer
// captured from an earlier attempt cannot be replayed into a later one.
//
// SBQ2's own expiry check bounds how long a descriptor is usable at all, and
// the epoch is inside the payload so nothing survives a membership change.
export function meshDescriptorPayload({ groupId, epoch, kind, fromFp, toFp, descriptor, nonce }) {
assertGroupId(groupId);
assertEpoch(epoch);
if (kind !== MESH_KINDS.OFFER && kind !== MESH_KINDS.ANSWER) {
fail('unknown mesh descriptor kind', 'bad_mesh_kind');
}
assertFingerprint(fromFp);
assertFingerprint(toFp);
if (fromFp === toFp) fail('a member cannot dial itself', 'bad_mesh_peer');
if (typeof descriptor !== 'string' || descriptor.length === 0
|| descriptor.length > GROUP_LIMITS.MAX_DESCRIPTOR_CHARS) {
fail('mesh descriptor is missing or oversized', 'bad_descriptor');
}
if (!(nonce instanceof Uint8Array) || nonce.length !== GROUP_LIMITS.MESH_NONCE_BYTES) {
fail('mesh nonce must be 16 bytes', 'bad_mesh_nonce');
}
return lp(
'securebit/group/mesh-descriptor/v1',
fromHex(groupId), u32(epoch), kind,
fromHex(fromFp), fromHex(toFp),
descriptor, nonce,
);
}
export async function signMeshDescriptor(subtle, privateKey, fields) {
const sig = await subtle.sign({ name: 'ECDSA', hash: 'SHA-384' }, privateKey, meshDescriptorPayload(fields));
return new Uint8Array(sig);
}
export async function verifyMeshDescriptor(subtle, publicKey, fields, signature) {
if (!(signature instanceof Uint8Array)
|| signature.length < GROUP_LIMITS.MIN_SIG_BYTES
|| signature.length > GROUP_LIMITS.MAX_SIG_BYTES) {
return false;
}
let payload;
try {
payload = meshDescriptorPayload(fields);
} catch (_) {
return false;
}
try {
return await subtle.verify({ name: 'ECDSA', hash: 'SHA-384' }, publicKey, signature, payload);
} catch (_) {
return false;
}
}
/**
* The bytes a link probe is signed over.
*
* A probe is how a member says "the pairwise chat you are reading this on is
* me, member <fp>". It exists because two members can perfectly well already
* hold a verified 1:1 chat with each other before the group was formed, and
* dialling a second connection between them would be pure waste.
*
* The claim has to be authenticated, and it has to be authenticated TO THIS
* SESSION. A bare signed claim would be replayable: any member could capture
* one and present it on their own link to impersonate its author, and group
* traffic meant for that member would then be encrypted to the impersonator's
* pairwise session, which is a plaintext disclosure and not merely a routing
* mistake.
*
* `linkFp` is what closes that. It is the pairwise session's own key
* fingerprint derived from the ECDH shared secret, so it is known to exactly
* the two endpoints of that session and to nobody else. A probe replayed onto
* any other session carries the wrong one and fails to verify. The receiver
* checks it against the fingerprint IT holds for the session the probe arrived
* on, never against a value inside the frame.
*/
export function linkProbePayload({ groupId, epoch, fp, linkFp }) {
assertGroupId(groupId);
assertEpoch(epoch);
assertFingerprint(fp);
if (typeof linkFp !== 'string' || linkFp.length === 0 || linkFp.length > 256) {
fail('link fingerprint is missing or oversized', 'bad_link_fp');
}
return lp('securebit/group/link-probe/v1', fromHex(groupId), u32(epoch), fromHex(fp), linkFp);
}
export async function signLinkProbe(subtle, privateKey, fields) {
const sig = await subtle.sign({ name: 'ECDSA', hash: 'SHA-384' }, privateKey, linkProbePayload(fields));
return new Uint8Array(sig);
}
export async function verifyLinkProbe(subtle, publicKey, fields, signature) {
if (!(signature instanceof Uint8Array)
|| signature.length < GROUP_LIMITS.MIN_SIG_BYTES
|| signature.length > GROUP_LIMITS.MAX_SIG_BYTES) {
return false;
}
let payload;
try {
payload = linkProbePayload(fields);
} catch (_) {
return false;
}
try {
return await subtle.verify({ name: 'ECDSA', hash: 'SHA-384' }, publicKey, signature, payload);
} catch (_) {
return false;
}
}
export { GroupCryptoError };
+103
View File
@@ -0,0 +1,103 @@
// Paced, serialised delivery of group frames over pairwise sessions.
//
// WHY THIS EXISTS
// ---------------
// A group frame rides `EnhancedSecureWebRTCManager.sendMessage`, which is the
// right call — it inherits the session's encryption, ratchet, replay protection
// and verification gate without adding a second path through the transport. But
// that path is rate limited, and the accounting is not what it looks like:
// `sendMessage` checks the limiter and then hands off to `sendSecureMessage`,
// which checks the SAME shared counter again. One frame therefore spends two of
// the ten burst slots available per second.
//
// Forming a group sends six frames back to back on one session — invite, two
// member keys, roster, commit, reveal — which asks for twelve slots out of ten.
// The overflow was rejected, and rejected as a plain `Error` with no code, so it
// surfaced to the user as a meaningless `frame_rejected`; the peer that never
// received the dropped frame simply waited until the ceremony timed out. Two
// different symptoms, one cause.
//
// The fix belongs here rather than in the limiter. Widening the burst allowance
// would loosen a control that exists for the 1:1 chat, to suit a caller that can
// perfectly well wait: five frames a second makes group formation take about a
// second and a half, which nobody notices.
//
// Sends are also SERIALISED per session. The protocol is order-dependent — a
// commitment must reach a peer before the reveal that opens it — and firing
// several `sendMessage` calls concurrently at one channel puts that ordering at
// the mercy of the manager's internal mutex.
//
// Time is injected so the pacing can be tested without waiting for it.
/**
* Minimum gap between two group frames on one session, in milliseconds.
* Two limiter slots per frame against a ten-per-second burst means five frames
* per second is the real budget; 260ms leaves a little headroom.
*/
export const GROUP_SEND_GAP_MS = 260;
/** How many times a rate-limited frame is retried before giving up. */
export const GROUP_SEND_ATTEMPTS = 4;
const isRateLimit = (error) => /rate limit/i.test(error?.message || '');
/**
* Build the `send` function a GroupSession is constructed with.
*
* @param {object} opts
* @param {(sessionId: string) => object|null} opts.getManager resolve a live manager
* @param {number} [opts.gapMs]
* @param {number} [opts.attempts]
* @param {() => number} [opts.now]
* @param {(ms: number) => Promise<void>} [opts.sleep]
*/
export function createGroupSender({
getManager,
gapMs = GROUP_SEND_GAP_MS,
attempts = GROUP_SEND_ATTEMPTS,
now = () => Date.now(),
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
} = {}) {
/** sessionId -> { chain, lastAt } */
const queues = new Map();
return async function sendGroupFrame(sessionId, frame) {
const manager = getManager(sessionId);
if (!manager || typeof manager.sendMessage !== 'function') throw new Error('no such link');
if (typeof manager.isConnected === 'function' && !manager.isConnected()) {
throw new Error('link is down');
}
const queue = queues.get(sessionId) || { chain: Promise.resolve(), lastAt: 0 };
const payload = JSON.stringify(frame);
const run = queue.chain.then(async () => {
const wait = gapMs - (now() - queue.lastAt);
if (wait > 0) await sleep(wait);
let lastError = null;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
await manager.sendMessage(payload);
queue.lastAt = now();
return true;
} catch (error) {
lastError = error;
// Only a rate-limit rejection is worth retrying. A closed
// channel or a refused verification gate will not improve by
// being asked again, and retrying would just delay the error
// the caller needs to see.
if (!isRateLimit(error)) throw error;
await sleep(gapMs * (attempt + 1));
}
}
throw lastError;
});
// The chain has to survive a failure. Leaving a rejected promise in it
// would wedge every later frame on that session behind the first error.
queue.chain = run.catch(() => {});
queues.set(sessionId, queue);
return run;
};
}
+102 -12
View File
@@ -345,6 +345,24 @@ class EnhancedSecureWebRTCManager {
?? EnhancedSecureWebRTCManager.DEFAULT_ICE_SERVERS.map(server => ({ ...server }))
}
};
/**
* Whether this connection may speak to the application at large.
*
* A manager announces its lifecycle on `document` new-connection,
* peer-disconnect, real-security-calculated and the header and the
* chat shell listen, because for an ordinary chat those events ARE the
* application's state changing.
*
* A group mesh link is not an ordinary chat. It is a routing detail with
* no window of its own, and letting one broadcast would have it reset the
* security badge and the connection banner belonging to whatever chat the
* user happens to be looking at a link the user never opened tearing
* down the display of one they did. Such a manager is muted here: its own
* callbacks still fire, so the group learns everything it needs.
*/
this._emitGlobalEvents = config.emitGlobalEvents !== false;
this._ipLeakWarningShown = false;
// Initialize own logging system
@@ -4049,6 +4067,61 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
}
}
/**
* Release a link that a GROUP authenticated, with no human in the loop.
*
* WHY THIS IS NOT A BYPASS
* ------------------------
* The SAS comparison exists to answer one question: is the peer who
* completed this handshake the person we meant to talk to? For a 1:1 chat
* only a human can answer it, which is why _setVerifiedStatus refuses every
* SAS-shaped transition that no human confirmed.
*
* A mesh link inside a group has already answered it, earlier and by a
* different route. The descriptor that opened this connection was signed
* with a group identity key; that key's fingerprint is named in a roster
* signed by the admin; and the group's safety code which every member
* compared out of band before any of this was allowed to start covers
* that exact set of fingerprints. Asking the two people to also read seven
* digits at each other for every one of up to twenty-eight pairs would not
* add a check, it would repeat one they already did, badly.
*
* So the guarantee is not weakened here, it is moved: the caller must have
* verified the group signature over the peer's descriptor BEFORE the
* transport was created. Everything this method can check for itself, it
* does the session must be SBQ2, its in-band exchange must have completed,
* and the peer must have proved possession of the identity key that the
* commitment in that descriptor bound it to. A session that has not got that
* far is refused outright rather than released on the caller's word.
*
* @param {string} reason short audit label for why the group vouched
*/
markGroupLinkVerified(reason = 'group_roster_signature') {
const st = this._sbq2;
if (!this._isSbq2() || !st || !st.completed || !st.proofVerified || !st.keysDerived) {
throw new Error('Group link cannot be released: the in-band handshake has not completed');
}
if (!this.encryptionKey || !this.macKey) {
throw new Error('Group link cannot be released: session keys are missing');
}
if (this.isVerified) return true;
// There is no peer confirmation to wait for and none to send: both sides
// reach this independently, from the same roster.
this.localVerificationConfirmed = true;
this.remoteVerificationConfirmed = true;
this.bothVerificationsConfirmed = true;
this._setVerifiedStatus(true, 'GROUP_ROSTER_SIGNATURE', {
reason,
timestamp: Date.now()
});
this._enforceVerificationGate('group_link_release', false);
this.onStatusChange?.('verified');
try { this.processMessageQueue(); } catch (_) {}
return true;
}
/**
* Create AAD (Additional Authenticated Data) for file messages
* This binds file messages to the current session and prevents replay attacks
@@ -4581,6 +4654,23 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
// SBQ2 — compact descriptor + in-band key exchange
// ========================================================================
/**
* Announce a lifecycle change to the application, unless this connection is
* muted. See `_emitGlobalEvents` in the constructor for why one would be.
*/
_dispatchAppEvent(event) {
// Called as `this._dispatchAppEvent?.(...)` everywhere, deliberately.
// Announcing a lifecycle change is the least important thing any of
// these paths does — several of them are teardown — and an announcement
// must never be what stops a connection from being cleaned up.
if (!this._emitGlobalEvents) return false;
try {
return document.dispatchEvent(event);
} catch (_) {
return false;
}
}
/** True once this connection has latched onto the SBQ2 handshake. */
_isSbq2() { return this._handshakeMode === 'sbq2'; }
@@ -8020,7 +8110,7 @@ async processMessage(data) {
});
// Send an event about security level update
document.dispatchEvent(new CustomEvent('security-level-updated', {
this._dispatchAppEvent?.(new CustomEvent('security-level-updated', {
detail: {
timestamp: Date.now(),
manager: 'webrtc',
@@ -8042,7 +8132,7 @@ async processMessage(data) {
// FIX: Direct update if there is a calculation
if (this.lastSecurityCalculation) {
document.dispatchEvent(new CustomEvent('real-security-calculated', {
this._dispatchAppEvent?.(new CustomEvent('real-security-calculated', {
detail: {
securityData: this.lastSecurityCalculation,
webrtcManager: this,
@@ -8278,7 +8368,7 @@ async processMessage(data) {
this.lastSecurityCalculation = securityData;
document.dispatchEvent(new CustomEvent('real-security-calculated', {
this._dispatchAppEvent?.(new CustomEvent('real-security-calculated', {
detail: {
securityData: securityData,
webrtcManager: this,
@@ -11316,7 +11406,7 @@ async processMessage(data) {
});
// Dispatch event about new connection
document.dispatchEvent(new CustomEvent('new-connection', {
this._dispatchAppEvent?.(new CustomEvent('new-connection', {
detail: {
type: 'offer',
timestamp: currentTimestamp,
@@ -11528,7 +11618,7 @@ async processMessage(data) {
bindingTag: await sbq2BindingTag(digest, offerBytes),
});
document.dispatchEvent(new CustomEvent('new-connection', {
this._dispatchAppEvent?.(new CustomEvent('new-connection', {
detail: { type: 'answer', timestamp: Date.now(), operationId }
}));
@@ -12171,7 +12261,7 @@ async processMessage(data) {
});
// Dispatch event about new connection
document.dispatchEvent(new CustomEvent('new-connection', {
this._dispatchAppEvent?.(new CustomEvent('new-connection', {
detail: {
type: 'answer',
timestamp: currentTimestamp,
@@ -13851,7 +13941,7 @@ async processMessage(data) {
// Anything the user sent into the dead channel goes out now.
this.processMessageQueue();
try {
document.dispatchEvent(new CustomEvent('connection-recovered', {
this._dispatchAppEvent?.(new CustomEvent('connection-recovered', {
detail: { timestamp: Date.now() }
}));
} catch (_) { /* non-DOM host */ }
@@ -14315,7 +14405,7 @@ async processMessage(data) {
this.fileTransferSystem = null;
}
document.dispatchEvent(new CustomEvent('peer-disconnect', {
this._dispatchAppEvent?.(new CustomEvent('peer-disconnect', {
detail: {
reason: 'connection_lost',
timestamp: Date.now()
@@ -14398,7 +14488,7 @@ async processMessage(data) {
this.onKeyExchange('');
this.onVerificationRequired('');
document.dispatchEvent(new CustomEvent('peer-disconnect', {
this._dispatchAppEvent?.(new CustomEvent('peer-disconnect', {
detail: {
reason: reason,
timestamp: Date.now()
@@ -14528,13 +14618,13 @@ async processMessage(data) {
});
});
document.dispatchEvent(new CustomEvent('peer-disconnect', {
this._dispatchAppEvent?.(new CustomEvent('peer-disconnect', {
detail: {
reason: 'user_disconnect',
timestamp: Date.now()
}
}));
document.dispatchEvent(new CustomEvent('connection-cleaned', {
this._dispatchAppEvent?.(new CustomEvent('connection-cleaned', {
detail: {
timestamp: Date.now(),
reason: 'user_cleanup'
@@ -15114,7 +15204,7 @@ checkFileTransferReadiness() {
try { this.onCallStateChanged?.(snapshot); } catch (_) {}
if (typeof document !== 'undefined') {
try {
document.dispatchEvent(new CustomEvent('securebit-call-state', {
this._dispatchAppEvent?.(new CustomEvent('securebit-call-state', {
detail: { managerId: this._managerId || null, state: snapshot }
}));
} catch (_) {}
+359
View File
@@ -0,0 +1,359 @@
// Groups registry for SecureBit.chat.
//
// Same contract as sessionsStore.js, and deliberately a SEPARATE reducer rather
// than a slice bolted onto that one: a group is built out of pairwise sessions
// but owns none of them, and keeping the two stores apart means adding groups
// cannot disturb the 1:1 state that every existing test covers.
//
// What lives here is only what React renders. The non-serializable half of a
// group — its ECDSA identity key pair, the running commit/reveal ceremony, the
// per-member imported verifying keys, the outbound sequence counter — lives
// OUTSIDE this state in a ref-held Map keyed by groupId, exactly the way
// managersRef holds the WebRTC managers. Key material must never reach a
// reducer: it would be cloned on every dispatch and retained by React's state
// history, which is the opposite of what a non-extractable key is for.
//
// groupId is SHARED with the other members (it identifies the group on the
// wire), unlike sessionId, which is local-only. Member identity is the
// fingerprint of a member's group identity key — never a session id, and never
// a name the peer supplied.
import { GROUP_LIMITS } from '../group/groupCrypto.js';
export const GROUP_ACTIONS = Object.freeze({
CREATE_GROUP: 'CREATE_GROUP',
REMOVE_GROUP: 'REMOVE_GROUP',
SET_ACTIVE_GROUP: 'SET_ACTIVE_GROUP',
SET_PHASE: 'SET_PHASE',
SET_MEMBERS: 'SET_MEMBERS',
PATCH_MEMBER: 'PATCH_MEMBER',
SET_SAS: 'SET_SAS',
CONFIRM_SAS: 'CONFIRM_SAS',
ADD_MESSAGE: 'ADD_MESSAGE',
SET_MESSAGES: 'SET_MESSAGES',
UPDATE_MESSAGE_STATUS: 'UPDATE_MESSAGE_STATUS',
INCREMENT_UNREAD: 'INCREMENT_UNREAD',
CLEAR_UNREAD: 'CLEAR_UNREAD',
RENAME: 'RENAME',
SET_ERROR: 'SET_ERROR',
});
/**
* A group's lifecycle.
*
* The order matters and the UI depends on it: nothing may be sent or displayed
* as group traffic until `ready`, and `ready` is reachable only through
* `awaiting_sas`, where a human confirmed the code. A group that skips that step
* is a group whose introduced members were never authenticated by anyone.
*/
export const GROUP_PHASE = Object.freeze({
FORMING: 'forming', // members chosen, identity keys being exchanged
COMMITTING: 'committing', // commitments in flight
REVEALING: 'revealing', // every commitment in, nonces in flight
AWAITING_SAS: 'awaiting_sas', // code computed, waiting for the humans
READY: 'ready', // confirmed; group traffic flows
FAILED: 'failed', // ceremony aborted; nothing flows
});
/** Per-member link state. 'self' is us; the rest describe the pairwise session. */
export const MEMBER_STATE = Object.freeze({
SELF: 'self',
LINKED: 'linked', // pairwise session up and SAS-verified
PENDING: 'pending', // session exists but is not verified/connected yet
LOST: 'lost', // was linked, connection dropped
});
export const GROUP_PHASE_WORD = {
[GROUP_PHASE.FORMING]: 'Forming…',
[GROUP_PHASE.COMMITTING]: 'Exchanging commitments…',
[GROUP_PHASE.REVEALING]: 'Revealing…',
[GROUP_PHASE.AWAITING_SAS]: 'Compare the group code',
[GROUP_PHASE.READY]: 'Group ready',
[GROUP_PHASE.FAILED]: 'Group failed',
};
/** Two-letter monogram for the group tile. Mirrors monoInitials in sessionsStore. */
export function groupInitials(name) {
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
const a = words[0]?.[0] || '';
const b = words[1]?.[0] || words[0]?.[1] || '';
return (a + b).toUpperCase() || '##';
}
export function createGroupEntry(opts = {}) {
return {
id: opts.id,
name: opts.name || 'Group',
createdAt: opts.createdAt || Date.now(),
adminFp: opts.adminFp || '',
selfFp: opts.selfFp || '',
isAdmin: !!opts.isAdmin,
epoch: Number.isInteger(opts.epoch) ? opts.epoch : 1,
phase: opts.phase || GROUP_PHASE.FORMING,
// members: [{ fp, name, sessionId, state }]. Always stored in canonical
// fingerprint order so every device renders the same list.
members: Array.isArray(opts.members) ? [...opts.members].sort(byFingerprint) : [],
sasCode: '',
sasConfirmed: false,
messages: [],
unreadCount: 0,
error: null,
};
}
const NAME_ENCODER = new TextEncoder();
/** Trim a group name so its UTF-8 encoding fits the protocol's byte budget. */
function clampNameBytes(value) {
let out = String(value);
while (NAME_ENCODER.encode(out).length > GROUP_LIMITS.MAX_NAME_BYTES) out = out.slice(0, -1);
return out;
}
function byFingerprint(a, b) {
return a.fp < b.fp ? -1 : a.fp > b.fp ? 1 : 0;
}
export function createInitialGroupState() {
return { groups: {}, order: [], activeGroupId: null };
}
/** Patch one group, leaving every sibling referentially untouched. */
function patchGroup(state, id, patch) {
const group = state.groups[id];
if (!group) return state;
return { ...state, groups: { ...state.groups, [id]: { ...group, ...patch } } };
}
export function groupsReducer(state, action) {
const A = GROUP_ACTIONS;
switch (action.type) {
case A.CREATE_GROUP: {
const entry = action.entry || createGroupEntry(action);
if (!entry.id || state.groups[entry.id]) return state;
return {
groups: { ...state.groups, [entry.id]: entry },
order: [...state.order, entry.id],
activeGroupId: action.activate === false ? state.activeGroupId : entry.id,
};
}
case A.REMOVE_GROUP: {
const { id } = action;
if (!state.groups[id]) return state;
const groups = { ...state.groups };
delete groups[id];
const order = state.order.filter((x) => x !== id);
let activeGroupId = state.activeGroupId;
if (activeGroupId === id) {
const removedIdx = state.order.indexOf(id);
activeGroupId = order[Math.max(0, removedIdx - 1)] || order[0] || null;
}
return { groups, order, activeGroupId };
}
case A.SET_ACTIVE_GROUP: {
// null is legitimate: it means a 1:1 session took the foreground.
if (action.id === null) {
return state.activeGroupId === null ? state : { ...state, activeGroupId: null };
}
if (!state.groups[action.id] || state.activeGroupId === action.id) return state;
return { ...state, activeGroupId: action.id };
}
case A.SET_PHASE: {
const group = state.groups[action.id];
if (!group || group.phase === action.phase) return state;
// Leaving READY clears the confirmation: a membership change starts a
// new epoch with a new code, and a stale "confirmed" tick would tell
// the user they had checked something they had not.
const patch = { phase: action.phase };
if (action.phase !== GROUP_PHASE.READY && group.sasConfirmed) {
patch.sasConfirmed = false;
}
// The code is cleared only on the way BACK to a pre-code phase.
// AWAITING_SAS is where a code is born, so clearing it there would
// erase the digits the user is about to be shown.
if (action.phase !== GROUP_PHASE.READY && action.phase !== GROUP_PHASE.AWAITING_SAS) {
patch.sasCode = '';
}
if (action.phase !== GROUP_PHASE.FAILED) patch.error = null;
return patchGroup(state, action.id, patch);
}
case A.SET_MEMBERS: {
const group = state.groups[action.id];
if (!group) return state;
const members = Array.isArray(action.members) ? [...action.members].sort(byFingerprint) : group.members;
const patch = { members };
if (Number.isInteger(action.epoch)) patch.epoch = action.epoch;
return patchGroup(state, action.id, patch);
}
case A.PATCH_MEMBER: {
const group = state.groups[action.id];
if (!group) return state;
let changed = false;
const members = group.members.map((m) => {
if (m.fp !== action.fp) return m;
const next = { ...m, ...action.patch };
// Skip the dispatch entirely when nothing actually moved — link
// state churns on every ICE event and would otherwise re-render
// the whole group list continuously.
if (Object.keys(action.patch).every((k) => m[k] === next[k])) return m;
changed = true;
return next;
});
return changed ? patchGroup(state, action.id, { members }) : state;
}
case A.SET_SAS: {
const group = state.groups[action.id];
if (!group || group.sasCode === action.code) return state;
return patchGroup(state, action.id, { sasCode: action.code || '', sasConfirmed: false });
}
case A.CONFIRM_SAS: {
const group = state.groups[action.id];
if (!group) return state;
// Refuse the transition unless there is a code to have confirmed AND
// the group is actually waiting on that confirmation.
//
// Checking only for a code was not enough: a ceremony that reached
// AWAITING_SAS and then FAILED — a mismatched commitment, a member
// that vanished — kept its code, so confirming promoted a group whose
// verification had demonstrably gone wrong straight to READY. The
// phase is what says the code in hand is still the one being asked
// about, which mirrors the 1:1 rule that verified state comes only
// from the local user acting on something currently true.
if (!group.sasCode) return state;
if (group.phase !== GROUP_PHASE.AWAITING_SAS) return state;
if (group.sasConfirmed && group.phase === GROUP_PHASE.READY) return state;
return patchGroup(state, action.id, { sasConfirmed: true, phase: GROUP_PHASE.READY, error: null });
}
case A.ADD_MESSAGE: {
const group = state.groups[action.id];
if (!group) return state;
return patchGroup(state, action.id, { messages: [...group.messages, action.message] });
}
case A.SET_MESSAGES: {
const group = state.groups[action.id];
if (!group) return state;
const next = typeof action.updater === 'function' ? action.updater(group.messages) : action.messages;
return patchGroup(state, action.id, { messages: Array.isArray(next) ? next : [] });
}
case A.UPDATE_MESSAGE_STATUS: {
const group = state.groups[action.id];
if (!group) return state;
let changed = false;
const messages = group.messages.map((m) => {
if (String(m.mid) === String(action.mid) && m.status !== action.status) {
changed = true;
return { ...m, status: action.status };
}
return m;
});
return changed ? patchGroup(state, action.id, { messages }) : state;
}
case A.INCREMENT_UNREAD: {
const group = state.groups[action.id];
if (!group) return state;
return patchGroup(state, action.id, { unreadCount: group.unreadCount + 1 });
}
case A.CLEAR_UNREAD: {
const group = state.groups[action.id];
if (!group || group.unreadCount === 0) return state;
return patchGroup(state, action.id, { unreadCount: 0 });
}
case A.RENAME: {
const group = state.groups[action.id];
if (!group) return state;
// Clamped by BYTES, the unit the protocol enforces. A character slice
// against a byte budget lets a name in a multi-byte script through
// here and then fails when the roster carrying it is signed.
const name = clampNameBytes(String(action.name || '').trim()) || group.name;
return patchGroup(state, action.id, { name });
}
case A.SET_ERROR: {
const group = state.groups[action.id];
if (!group) return state;
const patch = { error: action.error || null };
if (action.error) patch.phase = GROUP_PHASE.FAILED;
return patchGroup(state, action.id, patch);
}
default:
return state;
}
}
// ---------------------------------------------------------------------------
// derivation for rendering
// ---------------------------------------------------------------------------
/** How many members currently have a usable pairwise link (including us). */
export function linkedCount(group) {
return group.members.filter((m) => m.state === MEMBER_STATE.SELF || m.state === MEMBER_STATE.LINKED).length;
}
/**
* A group is only fully up when every member is reachable. Partial connectivity
* is shown rather than hidden: in a mesh with no server, a member you cannot
* reach is a member who is not receiving your messages, and the sender is the
* only one who can know that.
*/
export function groupSub(group) {
if (group.phase !== GROUP_PHASE.READY) return GROUP_PHASE_WORD[group.phase] || 'Group';
const total = group.members.length;
const linked = linkedCount(group);
if (linked < total) return `${linked} of ${total} connected`;
return `${total} members · P2P mesh`;
}
export function groupDot(group) {
switch (group.phase) {
case GROUP_PHASE.READY:
return linkedCount(group) < group.members.length ? '#e3b341' : '#3ecf8e';
case GROUP_PHASE.FAILED:
return '#e5727a';
default:
return '#e3b341';
}
}
export function decorateGroup(group, activeGroupId) {
const lastMessage = [...group.messages].reverse().find(
(m) => !m.expired && typeof m.message === 'string' && m.message.trim(),
);
const sub = groupSub(group);
return {
id: group.id,
kind: 'group',
name: group.name,
mono: groupInitials(group.name),
dot: groupDot(group),
headerSub: sub,
phase: group.phase,
memberCount: group.members.length,
linkedCount: linkedCount(group),
preview: lastMessage ? lastMessage.message : sub,
unread: group.unreadCount > 0 ? (group.unreadCount > 99 ? '99+' : String(group.unreadCount)) : null,
verified: group.phase === GROUP_PHASE.READY && group.sasConfirmed,
active: group.id === activeGroupId,
inactive: group.id !== activeGroupId,
};
}
export function decorateGroups(state) {
return state.order
.map((id) => state.groups[id])
.filter(Boolean)
.map((g) => decorateGroup(g, state.activeGroupId));
}
+8 -1
View File
@@ -331,7 +331,14 @@ export function sessionsReducer(state, action) {
// Decorate a session into the shape the sidebar/header rendering consumes (avatar monogram,
// status dot, sub-text, last-message preview, unread badge). Pure derivation — no state.
export function decorateSession(session, activeSessionId) {
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && ((typeof m.message === 'string' && m.message.trim()) || m.voice));
// System notices are not conversation. Letting them win the preview put
// things like "Enhanced secure connection closed" in the rail where the last
// thing the peer actually said belongs — and the status line beside it was
// already saying the same thing, better.
const lastMessage = [...session.messages].reverse().find(
(m) => !m.expired && m.type !== 'system'
&& ((typeof m.message === 'string' && m.message.trim()) || m.voice),
);
const s = session.status;
const isUp = s === 'connected' || s === 'verified';
// 'reconnecting' is a live session whose path is being repaired — amber, not
+1 -1
View File
@@ -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 = '1786294476636';
const SW_BUILD_VERSION = '1787689263870';
// Load version from meta.json on install
async function getAppVersion() {
+22
View File
@@ -45,6 +45,12 @@ function closableChannel() {
};
const timer = setTimeout(() => {}, 10_000);
const manager = {
// A manager announces its lifecycle through _dispatchAppEvent rather
// than touching `document` directly, so that a connection with no window
// of its own — a group's mesh link — can be muted. The real method is
// borrowed here so this test still exercises the path the app uses.
_emitGlobalEvents: true,
_dispatchAppEvent: EnhancedSecureWebRTCManager.prototype._dispatchAppEvent,
intentionalDisconnect: false,
fileTransferSystem: { cleanup() { transferCleanups += 1; } },
dataChannel,
@@ -117,4 +123,20 @@ function closableChannel() {
assert.ok(dispatchedEvents.some(event => event.type === 'connection-cleaned'));
}
// A connection with no window of its own must not speak to the application.
//
// A group's mesh link is a routing detail, not a chat: it has no transcript and
// no header. Letting it broadcast peer-disconnect would have a link the user
// never opened reset the display of the chat they are actually looking at.
{
const dispatch = EnhancedSecureWebRTCManager.prototype._dispatchAppEvent;
const before = dispatchedEvents.length;
assert.equal(dispatch.call({ _emitGlobalEvents: false }, { type: 'peer-disconnect' }), false);
assert.equal(dispatchedEvents.length, before, 'a muted connection dispatches nothing');
dispatch.call({ _emitGlobalEvents: true }, { type: 'peer-disconnect' });
assert.equal(dispatchedEvents.length, before + 1, 'an ordinary chat still announces itself');
}
console.log('Disconnect cleanup tests passed');
+202
View File
@@ -0,0 +1,202 @@
// The seam between GroupSession and the reducer.
//
// Every other group test drives GroupSession directly and reads its fields. That
// left the path the UI actually renders from — emitted event, dispatched action,
// reducer state — completely uncovered, and it is where two bugs hid: a group
// name that the create dialog accepted but the protocol rejected, and a failure
// that reached the store but was rendered as "still working".
//
// This file mirrors app.jsx's groupEmitter exactly, so a change there that stops
// the code reaching the store fails here rather than on a user's screen.
import assert from 'node:assert/strict';
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope } =
await import('../src/group/GroupSession.js');
const {
groupsReducer, createInitialGroupState, createGroupEntry,
GROUP_ACTIONS: GA, GROUP_PHASE,
} = await import('../src/state/groupsStore.js');
const { GROUP_LIMITS, assertName } = await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
const bytes = (s) => new TextEncoder().encode(s).length;
/**
* A store plus the emitter app.jsx installs on every group. Kept structurally
* identical to the real one: same actions, same order, same SET_ACTIVE_GROUP on
* a code arriving.
*/
function makeStore() {
let state = createInitialGroupState();
const dispatch = (action) => { state = groupsReducer(state, action); };
const emitterFor = (gid) => (event, payload = {}) => {
switch (event) {
case 'phase': dispatch({ type: GA.SET_PHASE, id: gid, phase: payload.phase }); break;
case 'members': dispatch({ type: GA.SET_MEMBERS, id: gid, members: payload.members, epoch: payload.epoch }); break;
case 'roster': dispatch({ type: GA.RENAME, id: gid, name: payload.name }); break;
case 'sas':
dispatch({ type: GA.SET_SAS, id: gid, code: payload.code });
dispatch({ type: GA.SET_ACTIVE_GROUP, id: gid });
break;
case 'confirmed': dispatch({ type: GA.CONFIRM_SAS, id: gid }); break;
case 'error': dispatch({ type: GA.SET_ERROR, id: gid, error: payload.error }); break;
default: break;
}
};
return { get: () => state, dispatch, emitterFor };
}
/** Two peers on one link, each with its own store, formed end to end. */
async function formPair(groupName) {
const links = new Map();
const nodes = new Map();
const gid = GroupSession.newId();
const make = (name, isAdmin) => {
const store = makeStore();
const node = { name, store, session: null, errors: [] };
node.send = async (sid, frame) => {
const pair = links.get(sid);
if (!pair) return;
const other = pair[0] === name ? pair[1] : pair[0];
const target = nodes.get(other);
if (!target) return;
const wire = JSON.parse(JSON.stringify(frame));
if (groupFrameType(wire) === GROUP_FRAMES.INVITE && !target.session) {
const invite = decodeEnvelope(wire);
target.session = new GroupSession({
groupId: invite.gid, name: invite.name, isAdmin: false, subtle,
send: target.send, emit: target.store.emitterFor(invite.gid),
});
await target.session.init();
// Exactly what handleAcceptInvite does, in the same order.
target.store.dispatch({
type: GA.CREATE_GROUP,
entry: createGroupEntry({ id: invite.gid, name: invite.name, selfFp: target.session.selfFp }),
});
await target.session.acceptInvite(sid, invite);
return;
}
if (!target.session) return;
// The app does not await this and swallows nothing — it routes the
// rejection into SET_ERROR. Mirror that.
try {
await target.session.handleFrame(sid, wire);
} catch (error) {
target.errors.push(error?.code || 'unknown');
target.store.dispatch({ type: GA.SET_ERROR, id: wire.gid, error: error?.code || 'frame_rejected' });
}
};
if (isAdmin) {
node.session = new GroupSession({
groupId: gid, name: groupName, isAdmin: true, subtle,
send: node.send, emit: store.emitterFor(gid),
});
}
nodes.set(name, node);
return node;
};
const admin = make('admin', true);
const joiner = make('joiner', false);
links.set('A-B', ['admin', 'joiner']);
await admin.session.init();
admin.store.dispatch({
type: GA.CREATE_GROUP,
entry: createGroupEntry({
id: gid, name: groupName, selfFp: admin.session.selfFp,
adminFp: admin.session.selfFp, isAdmin: true,
members: admin.session._memberSnapshot(),
}),
});
await admin.session.invite([{ sessionId: 'A-B', name: 'Peer' }]);
return { gid, admin, joiner };
}
// ---------------------------------------------------------------------------
// the code reaches the store, on BOTH sides
// ---------------------------------------------------------------------------
{
const { gid, admin, joiner } = await formPair('Field team');
for (const node of [admin, joiner]) {
const group = node.store.get().groups[gid];
assert.ok(group, `${node.name} must have the group in its store`);
assert.deepEqual(node.errors, [], `${node.name} saw no rejected frames`);
assert.equal(group.phase, GROUP_PHASE.AWAITING_SAS,
`${node.name}: the store must reach the safety-code step`);
// The two things the modal reads to decide whether to show the digits and
// enable the confirm button. Either one missing is the reported bug.
assert.match(group.sasCode, /^\d{7}$/, `${node.name}: the code must be IN THE STORE, not just in the session`);
assert.equal(group.sasConfirmed, false, `${node.name}: shown, not yet confirmed`);
assert.equal(group.members.length, 2, `${node.name}: both members are in the store`);
}
// Both stores hold the same digits — the whole point of the ceremony.
assert.equal(
admin.store.get().groups[gid].sasCode,
joiner.store.get().groups[gid].sasCode,
'both sides must render the same code',
);
// The modal's own gate: with a code present, confirming is allowed and lands.
for (const node of [admin, joiner]) {
node.session.confirmSas();
node.store.dispatch({ type: GA.CONFIRM_SAS, id: gid });
const group = node.store.get().groups[gid];
assert.equal(group.phase, GROUP_PHASE.READY, `${node.name}: confirmed group is ready`);
assert.equal(group.sasConfirmed, true);
}
}
// ---------------------------------------------------------------------------
// a group name in a non-Latin script
// ---------------------------------------------------------------------------
{
// The bug: the create dialog capped input at MAX_NAME_BYTES *characters*, so
// this 36-character name (68 bytes) passed the dialog and then threw inside
// the admin's roster signing. Formation died with nothing on screen.
const cyrillic = 'Наша секретная группа для обсуждений';
assert.ok(bytes(cyrillic) > cyrillic.length, 'the test name really is multi-byte');
assert.ok(bytes(cyrillic) > 64, 'and it really did exceed the old limit');
assert.doesNotThrow(() => assertName(cyrillic), 'the protocol must accept a normal Cyrillic name');
const { gid, admin, joiner } = await formPair(cyrillic);
for (const node of [admin, joiner]) {
const group = node.store.get().groups[gid];
assert.deepEqual(node.errors, [], `${node.name}: a Cyrillic name must not break formation`);
assert.equal(group.phase, GROUP_PHASE.AWAITING_SAS, `${node.name}: reached the code`);
assert.match(group.sasCode, /^\d{7}$/);
}
// The limit still exists — it is just counted in the same unit everywhere.
const tooLong = 'я'.repeat(GROUP_LIMITS.MAX_NAME_BYTES);
assert.ok(bytes(tooLong) > GROUP_LIMITS.MAX_NAME_BYTES);
assert.throws(() => assertName(tooLong), /too long/);
}
// ---------------------------------------------------------------------------
// a failure is visible in the store, not disguised as progress
// ---------------------------------------------------------------------------
{
const { gid, admin } = await formPair('Broken');
admin.store.dispatch({ type: GA.SET_ERROR, id: gid, error: 'ceremony_timed_out' });
const group = admin.store.get().groups[gid];
assert.equal(group.phase, GROUP_PHASE.FAILED);
assert.equal(group.error, 'ceremony_timed_out');
// FAILED is distinguishable from the working phases, which is what the modal
// needs in order to stop claiming it is still exchanging nonces.
assert.notEqual(group.phase, GROUP_PHASE.REVEALING);
assert.notEqual(group.phase, GROUP_PHASE.COMMITTING);
// And a failed group can never be confirmed into readiness.
admin.store.dispatch({ type: GA.CONFIRM_SAS, id: gid });
assert.notEqual(admin.store.get().groups[gid].phase, GROUP_PHASE.READY,
'a failed group must not be confirmable');
}
console.log('group-app-integration.test.mjs: all assertions passed');
+322
View File
@@ -0,0 +1,322 @@
// Group cryptography: the safety code, membership operations and message
// signatures.
//
// The assertions that matter most are the ones about ORDER. A group safety code
// of seven digits is only safe because no member can reveal their nonce before
// every commitment is in — otherwise a member who introduces two others can
// grind their own keys until both victims see the same digits. That gate is
// asserted directly here, not inferred from the code shape, and so is the
// mismatch a real man-in-the-middle would produce.
import assert from 'node:assert/strict';
const {
GROUP_LIMITS,
MEMBER_OPS,
GroupSasCeremony,
generateGroupIdentity,
fingerprintSpki,
importMemberIdentity,
buildCommitment,
verifyCommitment,
computeGroupSas,
canonicalFingerprints,
memberOpPayload,
signMemberOp,
verifyMemberOp,
hashBody,
signGroupMessage,
verifyGroupMessage,
newGroupId,
randomBytes,
toHex,
fromHex,
toB64,
fromB64,
} = await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
const GID = newGroupId();
// ---------------------------------------------------------------------------
// identity keys
// ---------------------------------------------------------------------------
{
const alice = await generateGroupIdentity(subtle);
assert.equal(alice.fingerprint.length, 64, 'a fingerprint is SHA-256 in hex');
assert.equal(alice.keyPair.privateKey.extractable, false, 'the signing key must not be extractable');
// The fingerprint a peer computes from the published bytes must equal ours.
const imported = await importMemberIdentity(subtle, alice.spki);
assert.equal(imported.fingerprint, alice.fingerprint, 'both sides must name a member identically');
// A member is named by what their key hashes to, never by what they claim.
const bob = await generateGroupIdentity(subtle);
assert.notEqual(bob.fingerprint, alice.fingerprint);
// Garbage SPKI is refused rather than producing a usable member.
await assert.rejects(
() => importMemberIdentity(subtle, new Uint8Array(120).fill(7)),
/valid P-384 public key/,
);
await assert.rejects(
() => fingerprintSpki(subtle, new Uint8Array(8)),
/SPKI length out of range/,
);
}
// ---------------------------------------------------------------------------
// commitments
// ---------------------------------------------------------------------------
{
const fp = toHex(randomBytes(32));
const nonce = randomBytes(32);
const fields = { groupId: GID, epoch: 1, fingerprint: fp, nonce };
const commitment = await buildCommitment(subtle, fields);
assert.equal(commitment.length, GROUP_LIMITS.COMMIT_BYTES);
assert.equal(await verifyCommitment(subtle, commitment, fields), true);
// Every bound field is really bound.
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, epoch: 2 }), false, 'epoch is bound');
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, groupId: newGroupId() }), false, 'group id is bound');
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, fingerprint: toHex(randomBytes(32)) }), false, 'member is bound');
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, nonce: randomBytes(32) }), false, 'nonce is bound');
// Malformed input returns false rather than throwing into the caller.
assert.equal(await verifyCommitment(subtle, new Uint8Array(4), fields), false);
await assert.rejects(() => buildCommitment(subtle, { ...fields, nonce: randomBytes(8) }), /nonce must be 32 bytes/);
}
// ---------------------------------------------------------------------------
// the ordering gate — the reason seven digits is enough
// ---------------------------------------------------------------------------
{
const [a, b, c] = await Promise.all([
generateGroupIdentity(subtle), generateGroupIdentity(subtle), generateGroupIdentity(subtle),
]);
const members = [a.fingerprint, b.fingerprint, c.fingerprint];
const ceremony = new GroupSasCeremony({
groupId: GID, epoch: 1, selfFingerprint: a.fingerprint, memberFingerprints: members,
});
await ceremony.ownCommitment(subtle);
assert.equal(ceremony.commitmentsComplete, false);
// THE gate: no nonce leaves this device while a commitment is outstanding.
assert.throws(() => ceremony.reveal(), /cannot reveal before every member has committed/);
const bCeremony = new GroupSasCeremony({
groupId: GID, epoch: 1, selfFingerprint: b.fingerprint, memberFingerprints: members,
});
ceremony.acceptCommitment(b.fingerprint, await bCeremony.ownCommitment(subtle));
assert.throws(() => ceremony.reveal(), /cannot reveal/, 'two of three is still not all');
const cCeremony = new GroupSasCeremony({
groupId: GID, epoch: 1, selfFingerprint: c.fingerprint, memberFingerprints: members,
});
ceremony.acceptCommitment(c.fingerprint, await cCeremony.ownCommitment(subtle));
assert.equal(ceremony.commitmentsComplete, true);
assert.doesNotThrow(() => ceremony.reveal(), 'a complete commitment round unlocks the reveal');
// A member may not move after committing.
const other = new GroupSasCeremony({
groupId: GID, epoch: 1, selfFingerprint: b.fingerprint, memberFingerprints: members,
});
assert.throws(
() => ceremony.acceptCommitment(b.fingerprint, new Uint8Array(32).fill(9)),
/member changed their commitment/,
);
void other;
// Outsiders are refused outright.
assert.throws(
() => ceremony.acceptCommitment(toHex(randomBytes(32)), new Uint8Array(32)),
/commitment from a non-member/,
);
// A nonce that does not open its commitment fails the ceremony.
await assert.rejects(
() => ceremony.acceptReveal(subtle, b.fingerprint, randomBytes(32)),
/does not match the commitment/,
);
}
// ---------------------------------------------------------------------------
// an honest group converges on one code
// ---------------------------------------------------------------------------
/** Run a full commit -> reveal -> finish round between n honest members. */
async function honestCeremony(identities, { groupId = GID, epoch = 1 } = {}) {
const members = identities.map((i) => i.fingerprint);
const ceremonies = identities.map((i) => new GroupSasCeremony({
groupId, epoch, selfFingerprint: i.fingerprint, memberFingerprints: members,
}));
const commitments = [];
for (const c of ceremonies) commitments.push(await c.ownCommitment(subtle));
for (let i = 0; i < ceremonies.length; i++) {
for (let j = 0; j < ceremonies.length; j++) {
if (i !== j) ceremonies[i].acceptCommitment(members[j], commitments[j]);
}
}
const nonces = ceremonies.map((c) => c.reveal());
for (let i = 0; i < ceremonies.length; i++) {
for (let j = 0; j < ceremonies.length; j++) {
if (i !== j) await ceremonies[i].acceptReveal(subtle, members[j], nonces[j]);
}
}
return Promise.all(ceremonies.map((c) => c.finish(subtle)));
}
{
const identities = await Promise.all(
Array.from({ length: 5 }, () => generateGroupIdentity(subtle)),
);
const codes = await honestCeremony(identities);
assert.equal(new Set(codes).size, 1, 'every honest member must read the same digits');
assert.match(codes[0], /^\d{7}$/, 'the group code is seven digits, like the pairwise SAS');
}
// ---------------------------------------------------------------------------
// a man in the middle produces a MISMATCH — which is the whole point
// ---------------------------------------------------------------------------
{
// Bob and Carol are introduced by Mallory, who presents a different key to
// each of them. Neither can detect that from their own view alone; the group
// code is what differs when they compare it out loud.
const bob = await generateGroupIdentity(subtle);
const carol = await generateGroupIdentity(subtle);
const malloryToBob = await generateGroupIdentity(subtle);
const malloryToCarol = await generateGroupIdentity(subtle);
const bobsView = await honestCeremony([bob, carol, malloryToBob]);
const carolsView = await honestCeremony([bob, carol, malloryToCarol]);
assert.notEqual(
bobsView[0], carolsView[0],
'substituted key material must change the digits the victims read',
);
}
// ---------------------------------------------------------------------------
// the code does not depend on the order members were listed in
// ---------------------------------------------------------------------------
{
const fps = Array.from({ length: 4 }, () => toHex(randomBytes(32)));
const contributions = fps.map((fingerprint) => ({ fingerprint, nonce: randomBytes(32) }));
const forward = await computeGroupSas(subtle, { groupId: GID, epoch: 3, contributions });
const reversed = await computeGroupSas(subtle, { groupId: GID, epoch: 3, contributions: [...contributions].reverse() });
assert.equal(forward, reversed, 'member ordering must not change the code');
// But the epoch and the group do.
const nextEpoch = await computeGroupSas(subtle, { groupId: GID, epoch: 4, contributions });
assert.notEqual(forward, nextEpoch, 'a new epoch must produce a new code');
const otherGroup = await computeGroupSas(subtle, { groupId: newGroupId(), epoch: 3, contributions });
assert.notEqual(forward, otherGroup, 'the code is bound to the group');
// canonicalFingerprints is where the ordering and the limits are enforced.
assert.deepEqual(canonicalFingerprints([...fps].reverse()), [...fps].sort());
assert.throws(() => canonicalFingerprints([fps[0], fps[0]]), /duplicate member/);
assert.throws(() => canonicalFingerprints([fps[0]]), /at least two members/);
assert.throws(
() => canonicalFingerprints(Array.from({ length: 9 }, () => toHex(randomBytes(32)))),
/limited to 8 members/,
);
}
// ---------------------------------------------------------------------------
// membership operations
// ---------------------------------------------------------------------------
{
const admin = await generateGroupIdentity(subtle);
const bob = await generateGroupIdentity(subtle);
const carol = await generateGroupIdentity(subtle);
const { publicKey: adminKey } = await importMemberIdentity(subtle, admin.spki);
const fields = {
groupId: GID, epoch: 2, op: MEMBER_OPS.ADD,
memberFps: [admin.fingerprint, bob.fingerprint, carol.fingerprint],
name: 'Field team',
};
const sig = await signMemberOp(subtle, admin.keyPair.privateKey, fields);
assert.equal(await verifyMemberOp(subtle, adminKey, fields, sig), true);
// Every signed field is bound.
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, epoch: 3 }, sig), false, 'epoch is signed');
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, op: MEMBER_OPS.REMOVE }, sig), false, 'the operation is signed');
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, name: 'Field teams' }, sig), false, 'the name is signed');
assert.equal(
await verifyMemberOp(subtle, adminKey, { ...fields, memberFps: [admin.fingerprint, bob.fingerprint] }, sig),
false, 'dropping a member invalidates the operation',
);
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, groupId: newGroupId() }, sig), false, 'the group is signed');
// Someone else's key does not verify the admin's operation.
const { publicKey: bobKey } = await importMemberIdentity(subtle, bob.spki);
assert.equal(await verifyMemberOp(subtle, bobKey, fields, sig), false, 'only the admin can author membership');
// Reordering the member list is NOT a different operation — canonical order.
const shuffled = { ...fields, memberFps: [carol.fingerprint, admin.fingerprint, bob.fingerprint] };
assert.equal(await verifyMemberOp(subtle, adminKey, shuffled, sig), true, 'member order is canonicalised before signing');
// Malformed signatures are rejected without throwing.
assert.equal(await verifyMemberOp(subtle, adminKey, fields, new Uint8Array(4)), false);
assert.equal(await verifyMemberOp(subtle, adminKey, fields, 'not bytes'), false);
// Length-prefixed encoding: no two field sets can collide.
const a = memberOpPayload({ ...fields, name: 'ab' });
const b = memberOpPayload({ ...fields, name: 'a' });
assert.notEqual(toHex(a), toHex(b));
}
// ---------------------------------------------------------------------------
// group message signatures
// ---------------------------------------------------------------------------
{
const sender = await generateGroupIdentity(subtle);
const { publicKey } = await importMemberIdentity(subtle, sender.spki);
const body = 'meet at the usual place';
const bodyHash = await hashBody(subtle, body);
const fields = { groupId: GID, epoch: 1, seq: 7, senderFp: sender.fingerprint, bodyHash };
const sig = await signGroupMessage(subtle, sender.keyPair.privateKey, fields);
assert.equal(await verifyGroupMessage(subtle, publicKey, fields, sig), true);
// A different body under the same signature is what a tampering relay would
// have to produce, and it does not verify.
const otherHash = await hashBody(subtle, 'meet at the other place');
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, bodyHash: otherHash }, sig), false);
// Replaying one message under another sequence number or epoch fails too.
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, seq: 8 }, sig), false, 'seq is signed');
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, epoch: 2 }, sig), false, 'epoch is signed');
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, senderFp: toHex(randomBytes(32)) }, sig), false);
// Oversized bodies are refused before they are hashed.
await assert.rejects(
() => hashBody(subtle, 'x'.repeat(GROUP_LIMITS.MAX_BODY_BYTES + 1)),
/exceeds the group limit/,
);
}
// ---------------------------------------------------------------------------
// codecs bound their input
// ---------------------------------------------------------------------------
{
const bytes = randomBytes(48);
assert.equal(toHex(fromHex(toHex(bytes))), toHex(bytes));
assert.deepEqual(fromB64(toB64(bytes)), bytes);
assert.throws(() => fromHex('zz'), /not a hex string/);
assert.throws(() => fromHex('abc'), /not a hex string/);
// The base64 bound is applied before decoding, so a huge string cannot force
// a huge allocation.
assert.throws(() => fromB64('A'.repeat(100000)), /exceeds its limit/);
assert.throws(() => fromB64('!!!!', { max: 64 }), /malformed base64/);
}
console.log('group-crypto.test.mjs: all assertions passed');
+593
View File
@@ -0,0 +1,593 @@
// The mesh: how a group stops being a star.
//
// A group is created as a star — the admin holds a link to everyone, nobody
// else holds a link to anybody — and every message between two non-admins is
// carried by the admin. This file covers the step that ends that: each pair
// without a link dials one, over the relay path that already exists.
//
// What is asserted, in order of how much it matters:
// 1. a pair with no link between them ends up with a DIRECT one, and their
// messages stop being relayed;
// 2. a relayed descriptor that was tampered with is refused, so the member
// carrying it cannot put itself in the middle of the link;
// 3. an answer replayed from a different dial is refused;
// 4. exactly one side of each pair dials, so there is no glare to resolve;
// 5. a pair that cannot connect keeps working over the relay, and is not
// retried forever;
// 6. a chat two members already held is adopted rather than re-dialled, and
// a probe replayed onto a different chat does not bind;
// 7. the group survives the admin going away once the mesh is up — which is
// the whole point of not being a star.
import assert from 'node:assert/strict';
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope, encodeEnvelope } =
await import('../src/group/GroupSession.js');
const { GROUP_PHASE, MEMBER_STATE } = await import('../src/state/groupsStore.js');
const { toB64, generateGroupIdentity, signLinkProbe } =
await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
/** Let queued timers and promise chains settle. The mesh runs on setTimeout(0). */
async function tick(rounds = 12) {
for (let i = 0; i < rounds; i++) await new Promise((r) => setTimeout(r, 0));
}
// ---------------------------------------------------------------------------
// a virtual network
// ---------------------------------------------------------------------------
//
// Unlike the e2e harness, session ids here are LOCAL to each node — which is
// what they are in the app, and what makes the mesh's own bookkeeping testable:
// a dial produces one id on the caller and a different one on the answerer.
function makeNet() {
const nodes = new Map();
/** `${node}|${sessionId}` -> { peer, peerSessionId, up, linkFp } */
const chans = new Map();
let counter = 0;
const key = (n, s) => `${n}|${s}`;
/** Two endpoints of one channel. Each side addresses it by its own id. */
function link(a, aSid, b, bSid, { linkFp = null } = {}) {
const fp = linkFp || `linkfp-${++counter}`;
chans.set(key(a, aSid), { peer: b, peerSessionId: bSid, up: true, linkFp: fp });
chans.set(key(b, bSid), { peer: a, peerSessionId: aSid, up: true, linkFp: fp });
return fp;
}
function cut(a, aSid) {
const ch = chans.get(key(a, aSid));
if (!ch) return;
ch.up = false;
const back = chans.get(key(ch.peer, ch.peerSessionId));
if (back) back.up = false;
}
/** Links whose transport has come up but whose group has not been told yet. */
const pendingUp = [];
function node(name, { refuseDials = false, tamper = null } = {}) {
const events = [];
const n = {
name, events, session: null,
dropRelays: false,
meshCalls: { offers: 0, answers: 0, closed: [] },
};
n.send = async (sessionId, frame) => {
const ch = chans.get(key(name, sessionId));
if (!ch || !ch.up) throw new Error('no such link');
if (n.dropRelays && groupFrameType(frame) === GROUP_FRAMES.RELAY) return;
const target = nodes.get(ch.peer);
if (!target) return;
let wire = JSON.parse(JSON.stringify(frame));
if (tamper) wire = tamper(wire) || wire;
if (groupFrameType(wire) === GROUP_FRAMES.INVITE && !target.session) {
const invite = decodeEnvelope(wire);
target.session = new GroupSession({
groupId: invite.gid, name: invite.name, isAdmin: false,
subtle, send: target.send, emit: target.emit, mesh: target.mesh,
});
await target.session.acceptInvite(ch.peerSessionId, invite);
return;
}
if (!target.session) return;
await target.session.handleFrame(ch.peerSessionId, wire);
};
n.emit = (event, payload) => events.push({ event, payload });
// The transport half. A descriptor is a string that names the node and
// the local session it belongs to, which is all the wiring below needs.
n.mesh = {
createOffer: async (fp) => {
if (refuseDials) throw new Error('no network');
n.meshCalls.offers += 1;
const sessionId = `m:${name}:${++counter}`;
return { sessionId, descriptor: `OFF|${name}|${sessionId}|${fp.slice(0, 8)}` };
},
createAnswer: async (fp, descriptor) => {
if (refuseDials) throw new Error('no network');
n.meshCalls.answers += 1;
const [, offerNode, offerSid] = String(descriptor).split('|');
const sessionId = `m:${name}:${++counter}`;
return { sessionId, descriptor: `ANS|${name}|${sessionId}|${offerNode}|${offerSid}` };
},
acceptAnswer: async (sessionId, descriptor) => {
const [tag, ansNode, ansSid, offerNode, offerSid] = String(descriptor).split('|');
if (tag !== 'ANS') throw new Error('not an answer');
// The answer has to name the dial it belongs to. A real transport
// enforces this through the descriptor's binding tag.
if (offerNode !== name || offerSid !== sessionId) {
throw new Error('answer does not match the dial');
}
link(name, sessionId, ansNode, ansSid);
pendingUp.push([name, sessionId], [ansNode, ansSid]);
},
close: (sessionId) => {
n.meshCalls.closed.push(sessionId);
chans.delete(key(name, sessionId));
},
linkFingerprint: (sessionId) => chans.get(key(name, sessionId))?.linkFp || '',
};
nodes.set(name, n);
return n;
}
/** Tell both ends of every freshly built link that it is up, then settle. */
async function settle(rounds = 12) {
for (let i = 0; i < rounds; i++) {
await new Promise((r) => setTimeout(r, 0));
while (pendingUp.length) {
const [who, sid] = pendingUp.shift();
try { nodes.get(who)?.session?.setSessionState(sid, true); } catch (_) {}
}
}
}
/**
* Tear every group down.
*
* Not tidiness: a group holds live timers dial deadlines, and the backoff
* that re-arms a maintenance pass and in Node those keep the process
* alive long after the assertions are done. destroy() clears them, which is
* the same thing the app does when a group is closed.
*/
function shutdown() {
for (const n of nodes.values()) {
try { n.session?.destroy(); } catch (_) {}
}
}
return { nodes, node, link, cut, settle, shutdown, chans, key };
}
const last = (node, event) => [...node.events].reverse().find((e) => e.event === event)?.payload;
const all = (node, event) => node.events.filter((e) => e.event === event).map((e) => e.payload);
const memberOf = (node, fp) => node.session.members.get(fp);
/**
* A ready three-member group over a star: Alice is the admin and holds a link to
* Bob and to Carol; Bob and Carol have no link to each other.
*/
async function readyGroup(opts = {}) {
const net = makeNet();
const gid = GroupSession.newId();
const alice = net.node('alice', opts.alice);
const bob = net.node('bob', opts.bob);
const carol = net.node('carol', opts.carol);
net.link('alice', 'A>B', 'bob', 'B>A');
net.link('alice', 'A>C', 'carol', 'C>A');
alice.session = new GroupSession({
groupId: gid, name: 'Field team', isAdmin: true,
subtle, send: alice.send, emit: alice.emit, mesh: alice.mesh,
});
await alice.session.init();
await alice.session.invite([
{ sessionId: 'A>B', name: 'Bob' },
{ sessionId: 'A>C', name: 'Carol' },
]);
for (const n of [alice, bob, carol]) {
assert.equal(n.session.phase, GROUP_PHASE.AWAITING_SAS, `${n.name} must reach the code step`);
}
// The humans compare and confirm. This is the gate the mesh waits behind.
for (const n of [alice, bob, carol]) n.session.confirmSas();
return { net, gid, alice, bob, carol };
}
// ---------------------------------------------------------------------------
// 1. a star becomes a mesh
// ---------------------------------------------------------------------------
{
const { net, alice, bob, carol } = await readyGroup();
const bobFp = bob.session.selfFp;
const carolFp = carol.session.selfFp;
// Before the mesh runs, Bob and Carol only know each other through Alice.
assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.PENDING,
'Carol starts out with no direct link to Bob');
assert.equal(memberOf(bob, carolFp).sessionId, null);
await net.settle();
assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED,
'Bob must end up directly linked to Carol');
assert.equal(memberOf(carol, bobFp).state, MEMBER_STATE.LINKED,
'Carol must end up directly linked to Bob');
assert.ok(memberOf(bob, carolFp).sessionId, 'the link must be bound to a session');
// Exactly one side dialled: the smaller fingerprint.
const dialer = bobFp < carolFp ? bob : carol;
const answerer = bobFp < carolFp ? carol : bob;
assert.equal(dialer.meshCalls.offers, 1, 'the smaller fingerprint dials, once');
assert.equal(dialer.meshCalls.answers, 0, 'the dialer does not also answer');
assert.equal(answerer.meshCalls.offers, 0, 'the larger fingerprint does not dial');
assert.equal(answerer.meshCalls.answers, 1, 'the larger fingerprint answers, once');
// Alice already had links to both, so nothing was dialled for her.
assert.equal(alice.meshCalls.offers, 0, 'the admin dials nobody: it is already linked to everyone');
// And now a message between them goes direct rather than through Alice.
bob.events.length = 0; carol.events.length = 0; alice.events.length = 0;
await bob.session.sendText('the mesh is up');
await tick();
const heard = last(carol, 'message');
assert.ok(heard, 'Carol must receive Bob\'s message');
assert.equal(heard.body, 'the mesh is up');
assert.equal(heard.relayed, false, 'and it must arrive over the direct link, not relayed');
// Alice still gets her own copy, directly, as a member.
assert.equal(last(alice, 'message').relayed, false);
net.shutdown();
}
// ---------------------------------------------------------------------------
// 2. the relay cannot substitute a descriptor
// ---------------------------------------------------------------------------
//
// Alice carries every mesh dial between Bob and Carol. If she could swap the
// descriptor for her own, she would sit inside the link built to route around
// her. The signature over the descriptor is what stops that.
{
const net = makeNet();
const gid = GroupSession.newId();
const alice = net.node('alice', {
// Alice rewrites the descriptor inside every relayed mesh dial. She has
// to open the envelope to do it, which is exactly what a relaying member
// is able to do — and exactly why the signature is inside.
tamper: (wire) => {
if (groupFrameType(wire) !== GROUP_FRAMES.RELAY) return wire;
let frame;
try { frame = decodeEnvelope(wire); } catch (_) { return wire; }
const inner = frame?.inner;
if (!inner) return wire;
if (inner.type !== GROUP_FRAMES.MESH_OFFER && inner.type !== GROUP_FRAMES.MESH_ANSWER) return wire;
inner.d = 'OFF|alice|m:alice:evil|00000000';
return encodeEnvelope(frame);
},
});
const bob = net.node('bob');
const carol = net.node('carol');
net.link('alice', 'A>B', 'bob', 'B>A');
net.link('alice', 'A>C', 'carol', 'C>A');
alice.session = new GroupSession({
groupId: gid, name: 'Tampered', isAdmin: true,
subtle, send: alice.send, emit: alice.emit, mesh: alice.mesh,
});
await alice.session.init();
await alice.session.invite([
{ sessionId: 'A>B', name: 'Bob' },
{ sessionId: 'A>C', name: 'Carol' },
]);
for (const n of [alice, bob, carol]) n.session.confirmSas();
// The dial fails rather than completing against a substituted descriptor.
// handleFrame rejects, so the rejection surfaces through the caller.
const rejections = [];
const guard = (n) => {
const original = n.session.handleFrame.bind(n.session);
n.session.handleFrame = (...args) => original(...args).catch((e) => { rejections.push(e.code); });
};
guard(bob); guard(carol);
await net.settle();
assert.ok(rejections.includes('bad_signature'),
'a tampered mesh descriptor must fail its signature check');
const bobFp = bob.session.selfFp;
const carolFp = carol.session.selfFp;
assert.notEqual(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED,
'no link may be built from a descriptor the sender did not sign');
// The group still works — over the relay, exactly as before the dial.
bob.events.length = 0; carol.events.length = 0;
await bob.session.sendText('still talking');
await tick();
const heard = last(carol, 'message');
assert.ok(heard, 'a failed mesh dial must not cost the group its relay path');
assert.equal(heard.relayed, true, 'and that copy is relayed, which the reader is told');
net.shutdown();
}
// ---------------------------------------------------------------------------
// 3. an answer from another dial is refused
// ---------------------------------------------------------------------------
{
const { net, bob, carol } = await readyGroup();
await net.settle();
const bobFp = bob.session.selfFp;
const carolFp = carol.session.selfFp;
const dialer = bobFp < carolFp ? bob : carol;
const answerer = bobFp < carolFp ? carol : bob;
const peerFp = dialer === bob ? carolFp : bobFp;
// Reach into the completed dial and replay its answer under a fresh nonce.
// A nonce that is not the one this dial published must not be accepted, or
// an answer captured from any earlier attempt could be pushed into a later
// one.
const forged = {
type: GROUP_FRAMES.MESH_ANSWER,
gid: dialer.session.groupId,
epoch: dialer.session.epoch,
from: peerFp,
to: dialer.session.selfFp,
d: 'ANS|x|m:x:1|y|m:y:1',
n: toB64(new Uint8Array(16)),
sig: toB64(new Uint8Array(96)),
};
// The pair is already linked, so the dial is gone and the frame is dropped
// before any signature work — which is itself the assertion: a settled pair
// has nothing left for a replayed answer to attach to.
await dialer.session._onMeshAnswer(forged);
assert.equal(memberOf(dialer, peerFp).state, MEMBER_STATE.LINKED,
'a replayed answer must not disturb a link that is already up');
assert.equal(answerer.meshCalls.answers, 1, 'and must not provoke a second answer');
net.shutdown();
}
// ---------------------------------------------------------------------------
// 4. a pair that cannot connect stays on the relay, and stops trying
// ---------------------------------------------------------------------------
{
// Carol's transport refuses to build anything.
const { net, bob, carol } = await readyGroup({ bob: { refuseDials: true }, carol: { refuseDials: true } });
await net.settle(6);
const bobFp = bob.session.selfFp;
const carolFp = carol.session.selfFp;
assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.PENDING,
'a pair that cannot dial stays on the relay path');
assert.equal(memberOf(bob, carolFp).sessionId, null,
'and is not left bound to a session that was never built');
// The group is unharmed: messages still flow through Alice.
bob.events.length = 0; carol.events.length = 0;
await bob.session.sendText('relayed after all');
await tick();
assert.equal(last(carol, 'message')?.body, 'relayed after all');
assert.equal(last(carol, 'message')?.relayed, true);
// The failure is recorded with a backoff rather than retried in a loop.
const dialer = bobFp < carolFp ? bob : carol;
const peerFp = dialer === bob ? carolFp : bobFp;
const failure = dialer.session._meshFailures.get(peerFp);
assert.ok(failure, 'a failed dial must be recorded');
assert.ok(failure.attempts >= 1);
assert.ok(failure.nextAt > Date.now(), 'and must not be retried immediately');
net.shutdown();
}
// ---------------------------------------------------------------------------
// 5. a chat the two already had is adopted, not re-dialled
// ---------------------------------------------------------------------------
{
const { net, bob, carol } = await readyGroup();
// Bob and Carol already hold a verified 1:1 chat with each other, formed
// before the group existed. Its key fingerprint is the same on both ends,
// which is what a probe is signed against.
net.link('bob', 'B>C', 'carol', 'C>B', { linkFp: 'shared-link-fingerprint' });
// The app drives probing; here we do it directly. Only one side has to send
// — the other answers in kind, or the adoption would be one-sided.
await bob.session.probeSession('B>C');
await tick();
const bobFp = bob.session.selfFp;
const carolFp = carol.session.selfFp;
assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'the existing chat is adopted');
assert.equal(memberOf(bob, carolFp).sessionId, 'B>C', 'and bound by its own session id');
assert.equal(memberOf(carol, bobFp).state, MEMBER_STATE.LINKED, 'on both sides');
assert.equal(memberOf(carol, bobFp).sessionId, 'C>B');
// Whatever the mesh had started is abandoned in favour of the chat that
// already worked — the pair ends up on THAT one, not on a second connection.
await net.settle();
assert.equal(memberOf(bob, carolFp).sessionId, 'B>C',
'the pre-existing chat wins over anything the mesh was building');
assert.equal(memberOf(carol, bobFp).sessionId, 'C>B');
// A second probe on the same session is not sent again.
assert.equal(await bob.session.probeSession('B>C'), false);
// And messages between them travel over it, direct.
bob.events.length = 0; carol.events.length = 0;
await bob.session.sendText('over the chat we already had');
await tick();
assert.equal(last(carol, 'message')?.body, 'over the chat we already had');
assert.equal(last(carol, 'message')?.relayed, false);
net.shutdown();
}
// ---------------------------------------------------------------------------
// 6. a probe replayed onto a different chat does not bind
// ---------------------------------------------------------------------------
//
// This is the attack the link fingerprint exists for. Without it, any member
// could capture another member's probe and present it on their own chat, and
// group traffic for that member would then be encrypted to the impersonator.
{
const { net, alice, bob, carol } = await readyGroup();
const carolFp = carol.session.selfFp;
// Carol signs a probe for the chat SHE holds with Alice...
net.link('carol', 'C>X', 'alice', 'A>X', { linkFp: 'carols-own-link' });
const sig = await signLinkProbe(subtle, carol.session.identity.keyPair.privateKey, {
groupId: carol.session.groupId,
epoch: carol.session.epoch,
fp: carolFp,
linkFp: 'carols-own-link',
});
const probe = {
type: GROUP_FRAMES.PROBE,
gid: carol.session.groupId,
epoch: carol.session.epoch,
fp: carolFp,
sig: toB64(sig),
};
// ...and Bob replays it on a chat of his own, claiming to be Carol.
net.link('bob', 'B>M', 'alice', 'A>M', { linkFp: 'bobs-own-link' });
// Bob's group has no link to Carol yet, so the claim would otherwise take.
await assert.rejects(
() => bob.session._onProbe('B>M', probe),
(e) => e.code === 'bad_signature',
'a probe signed for one chat must not bind another',
);
const bobsCarol = memberOf(bob, carolFp);
assert.notEqual(bobsCarol.sessionId, 'B>M', 'the replayed probe must bind nothing');
net.shutdown();
}
// ---------------------------------------------------------------------------
// 7. the group outlives the admin once the mesh is up
// ---------------------------------------------------------------------------
//
// In a star, the admin going away partitions everyone else. That is the failure
// the mesh exists to remove.
{
const { net, alice, bob, carol } = await readyGroup();
await net.settle();
const bobFp = bob.session.selfFp;
const carolFp = carol.session.selfFp;
assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'precondition: the mesh formed');
// Alice's links drop on both sides.
net.cut('bob', 'B>A');
net.cut('carol', 'C>A');
bob.session.setSessionState('B>A', false);
carol.session.setSessionState('C>A', false);
await tick();
bob.events.length = 0; carol.events.length = 0;
const { delivered, total } = await bob.session.sendText('still here without the admin');
await tick();
assert.equal(total, 2, 'Bob still has two other members on the roster');
assert.equal(delivered, 1, 'the admin is offline and is reported as not reached');
assert.equal(last(carol, 'message')?.body, 'still here without the admin',
'but Carol receives it over the direct link the mesh built');
assert.equal(last(carol, 'message')?.relayed, false);
net.shutdown();
}
// ---------------------------------------------------------------------------
// 8. an unsolicited hello cannot add a member
// ---------------------------------------------------------------------------
//
// The admin publishes a roster when every invitee it is waiting on has replied.
// A hello that answers no invitation must not reach that branch, or any member
// who knows the group id could put an identity of their choosing into the group
// and have the admin sign it.
{
const { net, alice, bob } = await readyGroup();
const before = alice.session.members.size;
const beforeEpoch = alice.session.epoch;
const stranger = await generateGroupIdentity(subtle);
await alice.session.handleFrame('A>B', {
type: GROUP_FRAMES.HELLO,
gid: alice.session.groupId,
epoch: alice.session.epoch,
spki: toB64(stranger.spki),
});
assert.equal(alice.session.members.size, before,
'a hello that answers no invitation must not create a member');
assert.equal(alice.session.members.has(stranger.fingerprint), false);
assert.equal(alice.session.epoch, beforeEpoch, 'and must not push the group into a new epoch');
// The same frame wrapped in a relay — the route a member without a direct
// link would have to use — is refused for the same reason.
await bob.session.handleFrame('B>A', {
type: GROUP_FRAMES.RELAY,
gid: alice.session.groupId,
to: alice.session.selfFp,
hopped: false,
inner: {
type: GROUP_FRAMES.HELLO,
gid: alice.session.groupId,
epoch: alice.session.epoch,
spki: toB64(stranger.spki),
},
});
assert.equal(alice.session.members.has(stranger.fingerprint), false,
'and a relayed one is refused too');
net.shutdown();
}
// ---------------------------------------------------------------------------
// 9. a mesh link that dies is dialled again
// ---------------------------------------------------------------------------
//
// A link dying is not the same as the member going away. If the connection is
// beyond repair the pair goes back to the relay and dials again — which is the
// difference between a mesh that heals and one that only ever degrades.
{
const { net, bob, carol } = await readyGroup();
await net.settle();
const bobFp = bob.session.selfFp;
const carolFp = carol.session.selfFp;
assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'precondition: the mesh formed');
const dialer = bobFp < carolFp ? bob : carol;
const answerer = bobFp < carolFp ? carol : bob;
const offersBefore = dialer.meshCalls.offers;
// What the app does when a mesh manager exhausts its own ICE restarts.
dialer.session.unbindSession(dialer === bob ? carolFp : bobFp);
answerer.session.unbindSession(answerer === bob ? carolFp : bobFp);
assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.PENDING,
'a released member falls back to the relay, not to offline');
await net.settle();
assert.equal(dialer.meshCalls.offers, offersBefore + 1, 'the pair is dialled again');
assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'and the mesh heals');
assert.equal(memberOf(carol, bobFp).state, MEMBER_STATE.LINKED);
net.shutdown();
}
console.log('group-mesh.test.mjs: all assertions passed');
+186
View File
@@ -0,0 +1,186 @@
// Group frames against the transport's real rate limit.
//
// This is the test that was missing when group formation kept dying. The manager
// allows a burst of ten sends per second, but one group frame spends TWO of those
// slots — sendMessage checks the shared limiter and then sendSecureMessage checks
// it again — so the six frames formation sends back to back asked for twelve.
// The overflow was rejected as a plain Error with no code, which reached the user
// as a meaningless "frame_rejected", while the peer that never got the dropped
// frame waited until "ceremony_timed_out".
//
// The stand-in manager below reproduces that accounting exactly. Time is
// injected, so the pacing is asserted rather than waited for.
import assert from 'node:assert/strict';
const { createGroupSender, GROUP_SEND_GAP_MS } = await import('../src/group/groupSender.js');
/** A virtual clock: sleeps advance it instead of blocking. */
function makeClock() {
let t = 0;
return {
now: () => t,
sleep: async (ms) => { t += Math.max(0, ms); },
advance: (ms) => { t += ms; },
};
}
/**
* A manager with the real limiter's shape: ten slots per rolling second, and two
* slots consumed per outbound message.
*/
function makeManager(clock, { burst = 10, slotsPerSend = 2, connected = true } = {}) {
let windowStart = clock.now();
let used = 0;
const sent = [];
return {
sent,
isConnected: () => connected,
async sendMessage(payload) {
if (clock.now() - windowStart >= 1000) { windowStart = clock.now(); used = 0; }
if (used + slotsPerSend > burst) {
// Verbatim from EnhancedSecureWebRTCManager: a plain Error, no code.
throw new Error('Rate limit exceeded for message sending');
}
used += slotsPerSend;
sent.push({ at: clock.now(), payload });
return true;
},
};
}
// ---------------------------------------------------------------------------
// the six frames of group formation all arrive
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const manager = makeManager(clock);
const send = createGroupSender({
getManager: () => manager, now: clock.now, sleep: clock.sleep,
});
// Exactly what forming a group puts on one session.
const frames = ['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal']
.map((type, i) => ({ type, gid: 'a'.repeat(32), seq: i }));
await Promise.all(frames.map((f) => send('s1', f)));
assert.equal(manager.sent.length, 6, 'every formation frame must reach the transport');
// Order is protocol-critical: a commitment has to arrive before the reveal
// that opens it. Concurrent sends must not reorder.
assert.deepEqual(
manager.sent.map((s) => JSON.parse(s.payload).type),
['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal'],
'frames keep the order they were queued in',
);
// And they are spaced, which is what keeps them inside the burst budget.
for (let i = 1; i < manager.sent.length; i++) {
const gap = manager.sent[i].at - manager.sent[i - 1].at;
assert.ok(gap >= GROUP_SEND_GAP_MS,
`frame ${i} came ${gap}ms after the last, under the ${GROUP_SEND_GAP_MS}ms budget`);
}
}
// ---------------------------------------------------------------------------
// without pacing the same run fails — the bug this exists to prevent
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const manager = makeManager(clock);
const send = createGroupSender({
getManager: () => manager, now: clock.now, sleep: clock.sleep,
gapMs: 0, attempts: 1, // pacing and retries disabled
});
const results = await Promise.allSettled(
Array.from({ length: 6 }, (_, i) => send('s1', { type: 'g_commit', seq: i })),
);
const rejected = results.filter((r) => r.status === 'rejected');
assert.ok(rejected.length > 0, 'unpaced, the burst limit really does reject frames');
assert.match(rejected[0].reason.message, /Rate limit exceeded/);
// And the rejection carries no `code`, which is why it surfaced as the
// generic frame_rejected rather than anything a user could act on.
assert.equal(rejected[0].reason.code, undefined);
}
// ---------------------------------------------------------------------------
// a rate-limited frame is retried, not dropped
// ---------------------------------------------------------------------------
{
const clock = makeClock();
let calls = 0;
const manager = {
isConnected: () => true,
async sendMessage() {
calls++;
if (calls <= 2) throw new Error('Rate limit exceeded for secure message sending');
return true;
},
};
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
await send('s1', { type: 'g_commit' });
assert.equal(calls, 3, 'the frame is retried until it lands — losing one strands every member');
}
// ---------------------------------------------------------------------------
// failures that will not improve are surfaced, not retried
// ---------------------------------------------------------------------------
{
const clock = makeClock();
let calls = 0;
const manager = {
isConnected: () => true,
async sendMessage() { calls++; throw new Error('Data channel not ready'); },
};
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
await assert.rejects(() => send('s1', { type: 'g_commit' }), /Data channel not ready/);
assert.equal(calls, 1, 'a closed channel is reported at once rather than retried');
// One failure must not wedge the session: later frames still go out.
const ok = { isConnected: () => true, sent: 0, async sendMessage() { this.sent++; return true; } };
const send2 = createGroupSender({
getManager: (id) => (id === 'dead' ? manager : ok), now: clock.now, sleep: clock.sleep,
});
await assert.rejects(() => send2('dead', { type: 'g_commit' }));
await send2('dead', { type: 'g_reveal' }).catch(() => {});
await send2('live', { type: 'g_reveal' });
assert.equal(ok.sent, 1, 'a rejected frame does not block the queue behind it');
}
// ---------------------------------------------------------------------------
// a link that is gone is refused before anything is queued
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const send = createGroupSender({ getManager: () => null, now: clock.now, sleep: clock.sleep });
await assert.rejects(() => send('s1', { type: 'g_commit' }), /no such link/);
const offline = createGroupSender({
getManager: () => ({ isConnected: () => false, sendMessage: async () => true }),
now: clock.now, sleep: clock.sleep,
});
await assert.rejects(() => offline('s1', { type: 'g_commit' }), /link is down/);
}
// ---------------------------------------------------------------------------
// separate sessions do not queue behind each other
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const a = makeManager(clock);
const b = makeManager(clock);
const send = createGroupSender({
getManager: (id) => (id === 'a' ? a : b), now: clock.now, sleep: clock.sleep,
});
await Promise.all([send('a', { type: 'g_commit' }), send('b', { type: 'g_commit' })]);
assert.equal(a.sent.length, 1);
assert.equal(b.sent.length, 1);
assert.equal(a.sent[0].at, b.sent[0].at, 'each session paces independently');
}
console.log('group-sender.test.mjs: all assertions passed');
+707
View File
@@ -0,0 +1,707 @@
// A whole group, formed end to end over a virtual mesh.
//
// The topology is deliberately INCOMPLETE: Alice (the admin) holds a link to
// Bob and a link to Carol, and Bob and Carol have no link to each other. That is
// the real shape of a group at the moment it is created, and it is what forces
// the relay path — so this file covers both the happy case and the case the
// design actually has to survive.
//
// What is asserted, in order of how much it matters:
// 1. every member computes the SAME safety code, and only after a complete
// commit round;
// 2. a member the admin relays for still receives and verifies messages;
// 3. a forged or tampered message does not verify;
// 4. a member who sends two different bodies under one sequence number is
// caught.
import assert from 'node:assert/strict';
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope, encodeEnvelope } =
await import('../src/group/GroupSession.js');
const { GROUP_PHASE, MEMBER_STATE } = await import('../src/state/groupsStore.js');
const { toB64, fromB64, hashBody, signGroupMessage, generateGroupIdentity } =
await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
// ---------------------------------------------------------------------------
// a virtual mesh
// ---------------------------------------------------------------------------
/**
* Nodes are wired by named links. `send(sessionId, frame)` delivers to whichever
* endpoint of that link is not the sender, awaiting the handler so the whole
* cascade settles before the test continues.
*/
function makeMesh() {
const links = new Map(); // sessionId -> [nodeA, nodeB]
const nodes = new Map(); // name -> node
const events = new Map(); // name -> [{ event, payload }]
function connect(a, b, sessionId) {
links.set(sessionId, [a, b]);
}
function makeNode(name, { isAdmin, groupId, groupName }) {
const log = [];
events.set(name, log);
const node = {
name,
session: null,
dropRelays: false,
events: log,
};
const send = async (sessionId, frame) => {
const pair = links.get(sessionId);
// A link that does not exist rejects, exactly as sendGroupFrame does
// for a session with no manager. Returning quietly would let a test
// pass on a send that never happened.
if (!pair) throw new Error('no such link');
const other = pair[0] === name ? pair[1] : pair[0];
const target = nodes.get(other);
if (!target) return;
if (node.dropRelays && groupFrameType(frame) === GROUP_FRAMES.RELAY) return;
// A frame is JSON on the wire; round-trip it so nothing in the test
// shares an object reference the real transport would have severed.
const wire = JSON.parse(JSON.stringify(frame));
if (groupFrameType(wire) === GROUP_FRAMES.INVITE && !target.session) {
const invite = decodeEnvelope(wire);
target.session = new GroupSession({
groupId: invite.gid, name: invite.name, isAdmin: false,
subtle, send: target.send, emit: target.emit,
});
await target.session.acceptInvite(sessionId, invite);
return;
}
if (!target.session) return;
await target.session.handleFrame(sessionId, wire);
};
const emit = (event, payload) => { log.push({ event, payload }); };
node.send = send;
node.emit = emit;
if (isAdmin) {
node.session = new GroupSession({ groupId, name: groupName, isAdmin: true, subtle, send, emit });
}
nodes.set(name, node);
return node;
}
return { connect, makeNode, nodes };
}
const last = (node, event) => [...node.events].reverse().find((e) => e.event === event)?.payload;
const all = (node, event) => node.events.filter((e) => e.event === event).map((e) => e.payload);
// ---------------------------------------------------------------------------
// form a three-member group over an incomplete mesh
// ---------------------------------------------------------------------------
async function formGroup() {
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' });
const bob = mesh.makeNode('bob', {});
const carol = mesh.makeNode('carol', {});
// Alice knows both. Bob and Carol do not know each other — the relay case.
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'carol', 'A-C');
await alice.session.init();
await alice.session.invite([
{ sessionId: 'A-B', name: 'Bob' },
{ sessionId: 'A-C', name: 'Carol' },
]);
return { mesh, gid, alice, bob, carol };
}
{
const { alice, bob, carol } = await formGroup();
// Everyone adopted the roster and reached the code.
for (const node of [alice, bob, carol]) {
assert.equal(node.session.phase, GROUP_PHASE.AWAITING_SAS,
`${node.name} must reach the safety-code step`);
assert.equal(node.session.members.size, 3, `${node.name} must see three members`);
}
// THE assertion: one group, one code.
const codes = [alice, bob, carol].map((n) => n.session.sasCode);
assert.equal(new Set(codes).size, 1, 'every member must read the same digits');
assert.match(codes[0], /^\d{7}$/);
// Bob and Carol learned about each other only through the admin's signed
// roster, and neither has a direct link to the other.
const bobsCarol = [...bob.session.members.values()].find((m) => m.fp === carol.session.selfFp);
assert.ok(bobsCarol, 'Bob must know Carol from the roster');
assert.equal(bobsCarol.state, MEMBER_STATE.PENDING, 'with no direct link, Carol is pending for Bob');
assert.equal(bobsCarol.sessionId, null);
// Nothing may be sent before the humans confirm.
await assert.rejects(() => bob.session.sendText('too early'), /group code has not been confirmed/);
// Confirm everywhere.
for (const node of [alice, bob, carol]) node.session.confirmSas();
for (const node of [alice, bob, carol]) {
assert.equal(node.session.phase, GROUP_PHASE.READY);
assert.equal(node.session.sasConfirmed, true);
assert.ok(last(node, 'confirmed'), 'the app is told the group is ready');
}
// ---- delivery over a direct link ----
await alice.session.sendText('roll call');
assert.equal(last(bob, 'message').body, 'roll call');
assert.equal(last(carol, 'message').body, 'roll call');
assert.equal(last(bob, 'message').fp, alice.session.selfFp, 'attributed to the signer');
// ---- delivery THROUGH the admin, between two members with no link ----
const result = await bob.session.sendText('on my way');
assert.equal(result.delivered, 2, 'Bob reaches both members: Alice directly, Carol relayed');
assert.equal(last(alice, 'message').body, 'on my way');
assert.equal(last(carol, 'message').body, 'on my way',
'a relayed message must arrive and verify');
assert.equal(last(carol, 'message').fp, bob.session.selfFp,
'the relay does not become the author');
// The relay is single-hop: Carol never forwards what she received.
assert.equal(carol.session._pendingCeremony.length, 0);
// ---- a duplicate is absorbed, not shown twice ----
const before = all(carol, 'message').length;
await bob.session._sendTo(carol.session.selfFp, {
type: GROUP_FRAMES.MESSAGE,
gid: bob.session.groupId,
epoch: bob.session.epoch,
seq: result.seq,
fp: bob.session.selfFp,
ts: Date.now(),
body: 'on my way',
sig: toB64(await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: bob.session.groupId, epoch: bob.session.epoch, seq: result.seq,
senderFp: bob.session.selfFp, bodyHash: await hashBody(subtle, 'on my way'),
})),
});
assert.equal(all(carol, 'message').length, before, 'a repeated frame is absorbed');
}
// ---------------------------------------------------------------------------
// the reveal gate really is closed until every commitment is in
// ---------------------------------------------------------------------------
{
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Slow' });
const bob = mesh.makeNode('bob', {});
const carol = mesh.makeNode('carol', {});
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'carol', 'A-C');
// Alice refuses to carry anything between Bob and Carol, so Carol's
// commitment never reaches Bob and Bob's never reaches Carol.
alice.dropRelays = true;
await alice.session.init();
await alice.session.invite([
{ sessionId: 'A-B', name: 'Bob' },
{ sessionId: 'A-C', name: 'Carol' },
]);
assert.equal(bob.session.phase, GROUP_PHASE.COMMITTING,
'an incomplete commit round leaves the member waiting, never revealing');
assert.equal(bob.session.ceremony.revealed, false, 'no nonce left the device');
assert.equal(bob.session.sasCode, '', 'and no code was produced');
assert.throws(() => bob.session.confirmSas(), /no group code to confirm/);
}
// ---------------------------------------------------------------------------
// forged and tampered messages
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
// A body swapped after signing does not verify.
const bodyHash = await hashBody(subtle, 'the real text');
const sig = await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 40, senderFp: bob.session.selfFp, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 40, fp: bob.session.selfFp,
ts: Date.now(), body: 'the SWAPPED text', sig: toB64(sig),
}),
/signature did not verify/,
);
// Alice cannot sign as Bob, however well placed she is to relay for him.
const aliceSig = await signGroupMessage(subtle, alice.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 41, senderFp: bob.session.selfFp, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 41, fp: bob.session.selfFp,
ts: Date.now(), body: 'the real text', sig: toB64(aliceSig),
}),
/signature did not verify/,
);
// An outsider with a perfectly valid signature is still not a member.
const outsider = await generateGroupIdentity(subtle);
const outsiderSig = await signGroupMessage(subtle, outsider.keyPair.privateKey, {
groupId: gid, epoch, seq: 1, senderFp: outsider.fingerprint, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 1, fp: outsider.fingerprint,
ts: Date.now(), body: 'the real text', sig: toB64(outsiderSig),
}),
/message from a non-member/,
);
// A message from an epoch the group has left is refused even though it verifies.
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch: epoch + 5, seq: 42, fp: bob.session.selfFp,
ts: Date.now(), body: 'the real text', sig: toB64(sig),
}),
/another epoch/,
);
}
// ---------------------------------------------------------------------------
// a member telling two halves of the group different things is detectable
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
const seq = 7;
const signed = async (body) => toB64(await signGroupMessage(
subtle, bob.session.identity.keyPair.privateKey,
{ groupId: gid, epoch, seq, senderFp: bob.session.selfFp, bodyHash: await hashBody(subtle, body) },
));
const frame = (body, sig) => ({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq, fp: bob.session.selfFp,
ts: Date.now(), body, sig,
});
const sellFrame = frame('sell', await signed('sell'));
const buyFrame = frame('buy', await signed('buy'));
await carol.session._onMessage(sellFrame);
assert.equal(last(carol, 'message').body, 'sell');
// The second, differently-signed body under the same sequence number is the
// split. Both signatures are valid, which is exactly what makes it provable.
await assert.rejects(
() => carol.session._onMessage(buyFrame),
/conflicting messages under one sequence number/,
);
const flagged = last(carol, 'inconsistency');
assert.ok(flagged, 'the app is told which member did it');
assert.equal(flagged.fp, bob.session.selfFp);
assert.equal(flagged.seq, seq);
}
// ---------------------------------------------------------------------------
// removing a member re-keys the group
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const codeBefore = alice.session.sasCode;
const epochBefore = alice.session.epoch;
await alice.session.removeMember(carol.session.selfFp);
assert.equal(alice.session.epoch, epochBefore + 1, 'removal opens a new epoch');
assert.equal(alice.session.members.size, 2);
assert.equal(bob.session.members.size, 2, 'the remaining member adopted the new roster');
assert.ok(!bob.session.members.has(carol.session.selfFp), 'Carol is gone from Bobs roster');
// A new epoch means a new code, and it must be compared again before
// anything is sent.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(bob.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.notEqual(alice.session.sasCode, codeBefore, 're-keying must change the digits');
assert.equal(alice.session.sasCode, bob.session.sasCode, 'and both remaining members agree');
assert.equal(alice.session.sasConfirmed, false);
await assert.rejects(() => alice.session.sendText('still here?'), /has not been confirmed/);
}
// ---------------------------------------------------------------------------
// relay hygiene
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A relay envelope already marked as hopped is not forwarded again.
let forwarded = 0;
const realSend = alice.session._send;
alice.session._send = async (sid, frame) => { forwarded++; return realSend(sid, frame); };
await alice.session._onRelay('A-B', {
type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: carol.session.selfFp,
hopped: true,
inner: { type: GROUP_FRAMES.MESSAGE, gid: alice.session.groupId },
});
assert.equal(forwarded, 0, 'a second hop is refused');
// A nested relay is refused outright.
await alice.session._onRelay('A-B', {
type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: carol.session.selfFp,
hopped: false,
inner: { type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: bob.session.selfFp },
});
assert.equal(forwarded, 0, 'relays do not nest');
alice.session._send = realSend;
// A frame for another group is ignored entirely.
const otherGid = GroupSession.newId();
await alice.session.handleFrame('A-B', { type: GROUP_FRAMES.MESSAGE, gid: otherGid, epoch: 1, seq: 1 });
// (no throw, no message emitted)
assert.equal(all(alice, 'message').length, 0);
}
// ---------------------------------------------------------------------------
// the envelope: what keeps a signature intact through the chat path
// ---------------------------------------------------------------------------
{
/**
* Stand-in for EnhancedSecureCryptoUtils.sanitizeMessage, which every group
* frame passes through on its way out: DOMPurify escapes the HTML-significant
* characters, control characters go, blank runs collapse, the string is
* trimmed and then cut to 2000 characters.
*/
const sanitizeLikeTheChatPath = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
.replace(/\r\n?/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
.substring(0, 2000);
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A body full of exactly the characters that path rewrites.
const hostile = 'if a < b && c > d then "quote" \n\n\n trailing ';
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
const bodyHash = await hashBody(subtle, hostile);
const sig = await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 99, senderFp: bob.session.selfFp, bodyHash,
});
const frame = {
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 99, fp: bob.session.selfFp,
ts: Date.now(), body: hostile, sig: toB64(sig),
};
// Without the envelope the chat path would rewrite the body and the
// signature would no longer match — the failure this design exists to avoid.
const bare = JSON.parse(sanitizeLikeTheChatPath(JSON.stringify(frame)));
assert.notEqual(bare.body, hostile, 'the chat path really does rewrite a raw body');
// With it, every byte survives.
const wrapped = encodeEnvelope(frame);
const throughTheWire = JSON.parse(sanitizeLikeTheChatPath(JSON.stringify(wrapped)));
assert.deepEqual(throughTheWire, wrapped, 'the envelope passes through untouched');
const recovered = decodeEnvelope(throughTheWire);
assert.equal(recovered.body, hostile, 'the body arrives byte for byte');
// And it verifies on the far side.
await carol.session._onMessage(recovered);
assert.equal(last(carol, 'message').body, hostile);
// The routing hints outside the encoding carry no authority.
assert.throws(() => decodeEnvelope({ ...wrapped, gid: GroupSession.newId() }),
/group id does not match/);
assert.throws(() => decodeEnvelope({ ...wrapped, t: GROUP_FRAMES.ROSTER }),
/type does not match/);
assert.throws(() => decodeEnvelope({ type: 'g_env', gid, t: 'g_msg', d: 'bm90IGpzb24=' }),
/Unexpected token|not valid JSON|no recognisable frame/);
// A frame that would be truncated by the chat path is refused at the source,
// where it is still a clear error rather than a corrupt payload.
assert.throws(
() => encodeEnvelope({ ...frame, body: 'x'.repeat(4000) }),
/exceeds the transport budget/,
);
// Every frame the protocol actually emits fits the budget, including the
// largest one: a roster for a full group.
const bigRoster = {
type: GROUP_FRAMES.ROSTER, gid, epoch: 1, op: 'create', name: 'x'.repeat(64),
adminSpki: toB64(new Uint8Array(120)),
members: Array.from({ length: 8 }, () => 'ab'.repeat(32)),
sig: toB64(new Uint8Array(96)),
};
assert.doesNotThrow(() => encodeEnvelope(bigRoster), 'a full 8-member roster must fit');
}
// ---------------------------------------------------------------------------
// nobody reveals before their own commitment is on the wire
// ---------------------------------------------------------------------------
{
// Ordering regression. Draining held frames before broadcasting our own
// commitment could complete the round on the spot and put a reveal out ahead
// of the commitment it belongs to. The peer then had a reveal it could not
// check and had to hold it, so the ceremony only finished if that one later
// frame arrived — and when it did not, every member sat at "exchanging
// nonces" waiting on a nonce that had already been sent.
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Pair' });
const bob = mesh.makeNode('bob', {});
mesh.connect('alice', 'bob', 'A-B');
const wire = [];
for (const node of [alice, bob]) {
const inner = node.send;
node.send = async (sid, frame) => {
wire.push(`${node.name}:${groupFrameType(frame)}`);
return inner(sid, frame);
};
}
// Bob's session is built later, from `bob.send`, so it picks the wrapper up
// on its own. Alice's already exists and captured the original at
// construction — spy on the session itself.
alice.session._send = alice.send;
await alice.session.init();
await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]);
for (const node of [alice, bob]) {
const commit = wire.indexOf(`${node.name}:${GROUP_FRAMES.COMMIT}`);
const reveal = wire.indexOf(`${node.name}:${GROUP_FRAMES.REVEAL}`);
assert.ok(commit >= 0, `${node.name} must broadcast a commitment`);
assert.ok(reveal >= 0, `${node.name} must broadcast a reveal`);
assert.ok(commit < reveal, `${node.name} must commit before revealing (wire: ${wire.join(' ')})`);
assert.equal(node.session._pendingCeremony.length, 0,
`${node.name} should not need to hold any ceremony frame`);
}
// A two-member group is the smallest one, and it must still converge.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(bob.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(alice.session.sasCode, bob.session.sasCode);
assert.match(alice.session.sasCode, /^\d{7}$/);
}
// ---------------------------------------------------------------------------
// leaving a group frees its members for the next one
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A group of three losing one is still a group; losing two is not, and the
// admin must be told it ended rather than throwing mid-teardown.
await alice.session.removeMember(carol.session.selfFp);
assert.equal(alice.session.members.size, 2);
await assert.rejects(
() => alice.session.removeMember(bob.session.selfFp),
/cannot drop below two members/,
);
// When the admin leaves, the remaining member's group is over: nobody else
// can sign a roster, so there is no next epoch and no code to compare again.
await alice.session.leave();
assert.ok(last(bob, 'ended'), 'the remaining member is told the group ended');
assert.equal(last(bob, 'ended').reason, 'admin_left');
assert.ok(!bob.session.members.has(alice.session.selfFp));
}
// ---------------------------------------------------------------------------
// a member who leaves disappears from everyone's list
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const carolFp = carol.session.selfFp;
await carol.session.leave();
// The admin's own view is what regressed: it deleted the member internally
// but never told the UI, so a member who had visibly left stayed on screen.
assert.ok(!alice.session.members.has(carolFp), 'the admin drops the departed member');
const adminView = last(alice, 'members');
assert.ok(adminView, 'the admin must emit a member list when someone leaves');
assert.ok(!adminView.members.some((m) => m.fp === carolFp),
'the departed member must be gone from the list the admin renders');
assert.equal(adminView.members.length, 2);
// And the remaining member learns it through the signed roster.
assert.ok(!bob.session.members.has(carolFp), 'the other member drops her too');
assert.ok(!last(bob, 'members').members.some((m) => m.fp === carolFp));
// Membership changed, so the code must be compared again.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(alice.session.sasCode, bob.session.sasCode);
}
// ---------------------------------------------------------------------------
// the admin can invite into a group that is already running
// ---------------------------------------------------------------------------
{
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' });
const bob = mesh.makeNode('bob', {});
const dana = mesh.makeNode('dana', {});
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'dana', 'A-D');
await alice.session.init();
await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]);
alice.session.confirmSas();
bob.session.confirmSas();
const firstCode = alice.session.sasCode;
const firstEpoch = alice.session.epoch;
assert.equal(alice.session.members.size, 2);
// The group stays usable while the invitation is outstanding — nothing about
// the membership has changed yet.
await alice.session.sendText('before dana');
assert.equal(last(bob, 'message').body, 'before dana');
await alice.session.addMembers([{ sessionId: 'A-D', name: 'Dana' }]);
for (const node of [alice, bob, dana]) {
assert.equal(node.session.members.size, 3, `${node.name} sees three members`);
assert.equal(node.session.epoch, firstEpoch + 1, `${node.name} moved to the next epoch`);
assert.equal(node.session.phase, GROUP_PHASE.AWAITING_SAS, `${node.name} must compare a new code`);
}
// A changed member set means a changed code — the old one no longer says
// anything about who is in the room.
const codes = [alice, bob, dana].map((n) => n.session.sasCode);
assert.equal(new Set(codes).size, 1, 'all three agree on the new code');
assert.notEqual(codes[0], firstCode, 'adding a member must change the code');
// And nothing flows until it is confirmed again, including for the member
// who was already verified a moment ago.
await assert.rejects(() => bob.session.sendText('too early'), /has not been confirmed/);
for (const node of [alice, bob, dana]) node.session.confirmSas();
await alice.session.sendText('welcome dana');
assert.equal(last(dana, 'message').body, 'welcome dana');
assert.equal(last(bob, 'message').body, 'welcome dana');
// Dana can reach Bob even with no direct link, relayed by the admin.
const result = await dana.session.sendText('hello bob');
assert.equal(result.delivered, 2);
assert.equal(last(bob, 'message').body, 'hello bob');
assert.equal(last(bob, 'message').fp, dana.session.selfFp);
}
// ---------------------------------------------------------------------------
// invitation rounds that should not happen
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// Only the admin invites.
await assert.rejects(
() => bob.session.addMembers([{ sessionId: 'X', name: 'Nope' }]),
/only the admin invites/,
);
// A session already carrying a member cannot be invited again — it would
// answer with a second identity key and take two slots in the safety code.
const bobsSession = [...alice.session.members.values()].find((m) => m.fp === bob.session.selfFp).sessionId;
await assert.rejects(
() => alice.session.addMembers([{ sessionId: bobsSession, name: 'Bob again' }]),
/already a member/,
);
// The ceiling is enforced before anything is sent.
await assert.rejects(
() => alice.session.addMembers(Array.from({ length: 7 }, (_, i) => ({ sessionId: `s${i}`, name: 'x' }))),
/limited to 8 members/,
);
// An add nobody answers leaves the group exactly as it was.
const epochBefore = alice.session.epoch;
const membersBefore = alice.session.members.size;
mesh_unreachable: {
// A link the mesh does not know about: the send fails, so the round is
// abandoned at once rather than half-applied.
await assert.rejects(
() => alice.session.addMembers([{ sessionId: 'nowhere', name: 'Ghost' }]),
/could not be sent/,
);
}
assert.equal(alice.session.epoch, epochBefore, 'a failed invitation does not open an epoch');
assert.equal(alice.session.members.size, membersBefore, 'and does not change the membership');
assert.equal(alice.session._pendingAdd, null, 'the round is cleared');
}
// ---------------------------------------------------------------------------
// a member going offline is not a member leaving
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const carolFp = carol.session.selfFp;
const carolsSession = [...alice.session.members.values()].find((m) => m.fp === carolFp).sessionId;
const epochBefore = alice.session.epoch;
const codeBefore = alice.session.sasCode;
alice.session.setSessionState(carolsSession, false);
// Still a member. Membership is a signed, epoch-ordered fact and a dropped
// connection does not change it — re-keying the group every time someone's
// network hiccups would make everyone re-compare a code for nothing.
assert.ok(alice.session.members.has(carolFp), 'an offline member is still a member');
assert.equal(alice.session.epoch, epochBefore, 'no new epoch for a dropped link');
assert.equal(alice.session.sasCode, codeBefore, 'and no new code to compare');
assert.equal(alice.session.phase, GROUP_PHASE.READY, 'the group stays usable');
// But unmistakably unreachable, and the UI is told.
assert.equal(alice.session.members.get(carolFp).state, MEMBER_STATE.LOST);
const view = last(alice, 'members');
assert.equal(view.members.find((m) => m.fp === carolFp).state, MEMBER_STATE.LOST,
'the rendered list marks them lost rather than dropping or hiding them');
// Sending reports the shortfall instead of pretending it reached everyone,
// and it names WHO was missed. A count alone cannot tell one absent member
// from another, which is what the app needs to know to stop repeating
// itself under every message for as long as somebody stays away.
const result = await alice.session.sendText('anyone there?');
assert.equal(result.total, 2);
assert.ok(result.delivered < result.total, 'delivery to an offline member is reported, not assumed');
assert.deepEqual(result.unreachable.map((m) => m.fp), [carolFp],
'the member who was missed is named, not just counted');
// The same shortfall reported twice is the same shortfall: the app compares
// these sets, so they have to be stable for an unchanged situation.
const again = await alice.session.sendText('still anyone there?');
assert.deepEqual(again.unreachable.map((m) => m.fp), [carolFp]);
// Coming back restores the link with no ceremony at all.
alice.session.setSessionState(carolsSession, true);
assert.equal(alice.session.members.get(carolFp).state, MEMBER_STATE.LINKED);
assert.equal(alice.session.epoch, epochBefore, 'reconnecting does not re-key either');
// Removing them, by contrast, IS a membership change and does re-key.
await alice.session.removeMember(carolFp);
assert.equal(alice.session.epoch, epochBefore + 1);
assert.ok(!alice.session.members.has(carolFp));
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS, 'a real removal makes everyone compare again');
}
console.log('group-session-e2e.test.mjs: all assertions passed');
+271
View File
@@ -0,0 +1,271 @@
// The groups reducer: isolation between groups, and the two transitions that
// carry security meaning — a group may not become "verified" without a code the
// user could have compared, and any membership change must drop that
// confirmation rather than carry it into a new epoch.
import assert from 'node:assert/strict';
const {
groupsReducer,
createInitialGroupState,
createGroupEntry,
GROUP_ACTIONS: A,
GROUP_PHASE,
MEMBER_STATE,
decorateGroup,
groupSub,
groupDot,
groupInitials,
linkedCount,
} = await import('../src/state/groupsStore.js');
const { GROUP_LIMITS } = await import('../src/group/groupCrypto.js');
const fp = (n) => String(n).repeat(64).slice(0, 64);
const SELF = fp(1);
const BOB = fp(2);
const CAROL = fp(3);
function members(...states) {
const fps = [SELF, BOB, CAROL];
return fps.map((f, i) => ({
fp: f,
name: ['You', 'Bob', 'Carol'][i],
sessionId: i === 0 ? null : `s${i}`,
state: states[i] || (i === 0 ? MEMBER_STATE.SELF : MEMBER_STATE.LINKED),
}));
}
function withTwoGroups() {
let state = createInitialGroupState();
state = groupsReducer(state, {
type: A.CREATE_GROUP,
entry: createGroupEntry({ id: 'g1', name: 'Field team', selfFp: SELF, adminFp: SELF, isAdmin: true, members: members() }),
});
state = groupsReducer(state, {
type: A.CREATE_GROUP,
entry: createGroupEntry({ id: 'g2', name: 'Review', selfFp: SELF, members: members() }),
});
return state;
}
// CREATE_GROUP activates the newest and preserves order.
{
const state = withTwoGroups();
assert.deepEqual(state.order, ['g1', 'g2']);
assert.equal(state.activeGroupId, 'g2');
assert.equal(state.groups.g1.phase, GROUP_PHASE.FORMING, 'a new group starts unformed');
assert.equal(state.groups.g1.sasConfirmed, false);
// A duplicate id is ignored rather than clobbering the live group.
const again = groupsReducer(state, { type: A.CREATE_GROUP, entry: createGroupEntry({ id: 'g1', name: 'Impostor' }) });
assert.equal(again, state, 'a repeated group id is a no-op');
}
// Isolation: touching g2 leaves g1 referentially untouched.
{
const before = withTwoGroups();
const g1Ref = before.groups.g1;
const after = groupsReducer(before, { type: A.ADD_MESSAGE, id: 'g2', message: { id: 1, message: 'hi', type: 'sent' } });
assert.equal(after.groups.g1, g1Ref, 'group 1 must be the same reference after editing group 2');
assert.equal(after.groups.g2.messages.length, 1);
assert.equal(before.groups.g2.messages.length, 0, 'the reducer is immutable');
}
// ---------------------------------------------------------------------------
// CONFIRM_SAS is the group's verification gate
// ---------------------------------------------------------------------------
{
let state = withTwoGroups();
// Without a computed code there is nothing the user could have compared, so
// the transition is refused. This is the group analogue of the 1:1 rule that
// verified state is never set by an inbound message.
const refused = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' });
assert.equal(refused, state, 'a group cannot be confirmed before a code exists');
assert.equal(state.groups.g1.sasConfirmed, false);
assert.equal(state.groups.g1.phase, GROUP_PHASE.FORMING);
// A code alone is not enough either: the group must be waiting on THIS code.
state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '4820193' });
assert.equal(state.groups.g1.sasCode, '4820193');
assert.equal(state.groups.g1.sasConfirmed, false, 'showing a code is not confirming it');
assert.equal(
groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }), state,
'a group still forming cannot be confirmed, even holding a code',
);
// Waiting on the code is what makes confirmation meaningful.
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS });
state = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' });
assert.equal(state.groups.g1.sasConfirmed, true);
assert.equal(state.groups.g1.phase, GROUP_PHASE.READY);
// A new code (new epoch) always lands unconfirmed.
state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '9911002' });
assert.equal(state.groups.g1.sasConfirmed, false, 'a fresh code must be compared again');
}
// A ceremony that reached a code and THEN failed cannot be confirmed.
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS });
state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '5550001' });
state = groupsReducer(state, { type: A.SET_ERROR, id: 'g1', error: 'commitment_mismatch' });
assert.equal(state.groups.g1.phase, GROUP_PHASE.FAILED);
assert.equal(state.groups.g1.sasCode, '5550001', 'the code survives so the UI can explain what failed');
// The dangerous case: a failed verification must not be promotable to ready
// just because a code is still lying around from before it failed.
assert.equal(
groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }), state,
'a failed ceremony must never be confirmable',
);
}
// Leaving READY drops the confirmation and the stale code.
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS });
state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '1234567' });
state = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' });
assert.equal(state.groups.g1.sasConfirmed, true);
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.COMMITTING });
assert.equal(state.groups.g1.sasConfirmed, false, 'a membership change un-verifies the group');
assert.equal(state.groups.g1.sasCode, '', 'and clears the code it was confirmed against');
assert.equal(state.groups.g2.sasConfirmed, false, 'sibling untouched');
}
// SET_ERROR fails the group; the phase follows.
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.SET_ERROR, id: 'g1', error: 'commitment_mismatch' });
assert.equal(state.groups.g1.phase, GROUP_PHASE.FAILED);
assert.equal(state.groups.g1.error, 'commitment_mismatch');
// Moving to any non-failed phase clears the error.
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.FORMING });
assert.equal(state.groups.g1.error, null);
}
// ---------------------------------------------------------------------------
// members are kept in canonical order, whatever order they arrive in
// ---------------------------------------------------------------------------
{
let state = createInitialGroupState();
const shuffled = [
{ fp: CAROL, name: 'Carol', sessionId: 's2', state: MEMBER_STATE.LINKED },
{ fp: SELF, name: 'You', sessionId: null, state: MEMBER_STATE.SELF },
{ fp: BOB, name: 'Bob', sessionId: 's1', state: MEMBER_STATE.LINKED },
];
state = groupsReducer(state, { type: A.CREATE_GROUP, entry: createGroupEntry({ id: 'g', name: 'X', members: shuffled }) });
assert.deepEqual(state.groups.g.members.map((m) => m.fp), [SELF, BOB, CAROL], 'sorted on create');
state = groupsReducer(state, { type: A.SET_MEMBERS, id: 'g', members: [...shuffled].reverse(), epoch: 4 });
assert.deepEqual(state.groups.g.members.map((m) => m.fp), [SELF, BOB, CAROL], 'sorted on update');
assert.equal(state.groups.g.epoch, 4);
}
// PATCH_MEMBER is a no-op when nothing actually moves (link state churns).
{
let state = withTwoGroups();
const before = state.groups.g1;
state = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: BOB, patch: { state: MEMBER_STATE.LINKED } });
assert.equal(state.groups.g1, before, 'setting a member state to what it already is must not re-render');
state = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: BOB, patch: { state: MEMBER_STATE.LOST } });
assert.notEqual(state.groups.g1, before);
assert.equal(state.groups.g1.members.find((m) => m.fp === BOB).state, MEMBER_STATE.LOST);
// An unknown fingerprint changes nothing.
const after = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: fp(9), patch: { state: MEMBER_STATE.LOST } });
assert.equal(after, state);
}
// ---------------------------------------------------------------------------
// unread and active pointer
// ---------------------------------------------------------------------------
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.INCREMENT_UNREAD, id: 'g1' });
state = groupsReducer(state, { type: A.INCREMENT_UNREAD, id: 'g1' });
assert.equal(state.groups.g1.unreadCount, 2);
assert.equal(state.groups.g2.unreadCount, 0, 'unread does not leak between groups');
state = groupsReducer(state, { type: A.CLEAR_UNREAD, id: 'g1' });
assert.equal(state.groups.g1.unreadCount, 0);
assert.equal(groupsReducer(state, { type: A.CLEAR_UNREAD, id: 'g1' }), state, 'clearing twice is a no-op');
// Removing the active group re-points to its neighbour.
state = groupsReducer(state, { type: A.SET_ACTIVE_GROUP, id: 'g2' });
state = groupsReducer(state, { type: A.REMOVE_GROUP, id: 'g2' });
assert.equal(state.activeGroupId, 'g1');
assert.deepEqual(state.order, ['g1']);
// A 1:1 session taking the foreground clears the active group.
state = groupsReducer(state, { type: A.SET_ACTIVE_GROUP, id: null });
assert.equal(state.activeGroupId, null);
}
// ---------------------------------------------------------------------------
// derivation for rendering — partial connectivity is surfaced, not hidden
// ---------------------------------------------------------------------------
{
const ready = createGroupEntry({ id: 'g', name: 'Field team', selfFp: SELF, members: members() });
ready.phase = GROUP_PHASE.READY;
ready.sasCode = '1234567';
ready.sasConfirmed = true;
assert.equal(linkedCount(ready), 3);
assert.equal(groupSub(ready), '3 members · P2P mesh');
assert.equal(groupDot(ready), '#3ecf8e');
const degraded = { ...ready, members: members(MEMBER_STATE.SELF, MEMBER_STATE.LOST, MEMBER_STATE.LINKED) };
assert.equal(linkedCount(degraded), 2);
assert.equal(groupSub(degraded), '2 of 3 connected', 'an unreachable member is a member not getting your messages');
assert.equal(groupDot(degraded), '#e3b341', 'partial connectivity reads amber, not green');
const forming = createGroupEntry({ id: 'h', name: 'New', members: members() });
assert.equal(groupSub(forming), 'Forming…');
assert.equal(groupDot(forming), '#e3b341');
const failed = { ...forming, phase: GROUP_PHASE.FAILED };
assert.equal(groupDot(failed), '#e5727a');
const d = decorateGroup(ready, 'other');
assert.equal(d.kind, 'group');
assert.equal(d.mono, 'FT');
assert.equal(d.verified, true);
assert.equal(d.active, false);
assert.equal(d.memberCount, 3);
assert.equal(d.preview, '3 members · P2P mesh', 'with no messages the preview shows the group state');
// A confirmed code alone is not "verified" if the phase regressed.
assert.equal(decorateGroup({ ...ready, phase: GROUP_PHASE.COMMITTING }, 'g').verified, false);
assert.equal(groupInitials('Field team'), 'FT');
assert.equal(groupInitials('Ops'), 'OP');
assert.equal(groupInitials(''), '##');
}
// RENAME trims and bounds.
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: ' Ops ' });
assert.equal(state.groups.g1.name, 'Ops');
state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: ' ' });
assert.equal(state.groups.g1.name, 'Ops', 'a blank rename keeps the old name');
state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: 'x'.repeat(400) });
assert.equal(state.groups.g1.name.length, GROUP_LIMITS.MAX_NAME_BYTES, 'the name is bounded');
// Bounded in BYTES, not characters: a Cyrillic name is two bytes per letter,
// so it must be cut at half the character count. Getting this wrong is what
// let a name through the UI that the roster signing then rejected.
state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: 'я'.repeat(400) });
const renamed = state.groups.g1.name;
assert.ok(new TextEncoder().encode(renamed).length <= GROUP_LIMITS.MAX_NAME_BYTES,
'a multi-byte name is clamped by bytes');
assert.equal(renamed.length, GROUP_LIMITS.MAX_NAME_BYTES / 2);
}
console.log('groups-reducer.test.mjs: all assertions passed');
+5
View File
@@ -55,6 +55,11 @@ function makeManager(overrides = {}) {
const sent = [];
const ui = [];
const mgr = {
// Lifecycle announcements go through _dispatchAppEvent rather than
// straight to `document`, so a connection with no window of its own — a
// group's mesh link — can be muted. An ordinary session is not.
_emitGlobalEvents: true,
_dispatchAppEvent: EnhancedSecureWebRTCManager.prototype._dispatchAppEvent,
isVerified: true,
isInitiator: true,
intentionalDisconnect: false,
+29
View File
@@ -176,4 +176,33 @@ function withTwoSessions() {
assert.equal(d.inactive, true);
}
// The rail preview shows conversation, not system notices.
{
let state = createInitialState();
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'a', peerLabel: 'work laptop' }) });
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
state = sessionsReducer(state, { type: A.ADD_MESSAGE, id: 'a', message: { id: 1, message: 'see you at six', type: 'received' } });
assert.equal(decorateSession(state.sessions.a, 'a').preview, 'see you at six');
// A closing notice arriving after it must not take the preview over: the
// status line next to it already says the connection is gone, and the last
// thing the peer actually said is what belongs there.
state = sessionsReducer(state, {
type: A.ADD_MESSAGE, id: 'a',
message: { id: 2, message: '🔌 Enhanced secure connection closed. Check connection status.', type: 'system' },
});
assert.equal(
decorateSession(state.sessions.a, 'a').preview, 'see you at six',
'a system notice must not become the chat preview',
);
// With nothing but system messages the preview falls back to the status text.
let bare = createInitialState();
bare = sessionsReducer(bare, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'b', peerLabel: 'x' }) });
bare = sessionsReducer(bare, { type: A.SET_STATUS, id: 'b', status: 'disconnected' });
bare = sessionsReducer(bare, { type: A.ADD_MESSAGE, id: 'b', message: { id: 1, message: 'Peer manually disconnected.', type: 'system' } });
assert.equal(decorateSession(bare.sessions.b, 'b').preview, 'Disconnected');
}
console.log('sessions-reducer.test.mjs: all assertions passed');