feat(groups): group chats, and a mesh rather than a star; release v6.1.1
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:
+875
-36
File diff suppressed because it is too large
Load Diff
@@ -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 member’s key did not match the identity claimed for it.',
|
||||
commitment_mismatch: 'A member’s 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 member’s 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)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user