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.
323 lines
15 KiB
JavaScript
323 lines
15 KiB
JavaScript
// Group cryptography: the safety code, membership operations and message
|
|
// signatures.
|
|
//
|
|
// The assertions that matter most are the ones about ORDER. A group safety code
|
|
// of seven digits is only safe because no member can reveal their nonce before
|
|
// every commitment is in — otherwise a member who introduces two others can
|
|
// grind their own keys until both victims see the same digits. That gate is
|
|
// asserted directly here, not inferred from the code shape, and so is the
|
|
// mismatch a real man-in-the-middle would produce.
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
const {
|
|
GROUP_LIMITS,
|
|
MEMBER_OPS,
|
|
GroupSasCeremony,
|
|
generateGroupIdentity,
|
|
fingerprintSpki,
|
|
importMemberIdentity,
|
|
buildCommitment,
|
|
verifyCommitment,
|
|
computeGroupSas,
|
|
canonicalFingerprints,
|
|
memberOpPayload,
|
|
signMemberOp,
|
|
verifyMemberOp,
|
|
hashBody,
|
|
signGroupMessage,
|
|
verifyGroupMessage,
|
|
newGroupId,
|
|
randomBytes,
|
|
toHex,
|
|
fromHex,
|
|
toB64,
|
|
fromB64,
|
|
} = await import('../src/group/groupCrypto.js');
|
|
|
|
const subtle = crypto.subtle;
|
|
const GID = newGroupId();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// identity keys
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const alice = await generateGroupIdentity(subtle);
|
|
assert.equal(alice.fingerprint.length, 64, 'a fingerprint is SHA-256 in hex');
|
|
assert.equal(alice.keyPair.privateKey.extractable, false, 'the signing key must not be extractable');
|
|
|
|
// The fingerprint a peer computes from the published bytes must equal ours.
|
|
const imported = await importMemberIdentity(subtle, alice.spki);
|
|
assert.equal(imported.fingerprint, alice.fingerprint, 'both sides must name a member identically');
|
|
|
|
// A member is named by what their key hashes to, never by what they claim.
|
|
const bob = await generateGroupIdentity(subtle);
|
|
assert.notEqual(bob.fingerprint, alice.fingerprint);
|
|
|
|
// Garbage SPKI is refused rather than producing a usable member.
|
|
await assert.rejects(
|
|
() => importMemberIdentity(subtle, new Uint8Array(120).fill(7)),
|
|
/valid P-384 public key/,
|
|
);
|
|
await assert.rejects(
|
|
() => fingerprintSpki(subtle, new Uint8Array(8)),
|
|
/SPKI length out of range/,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// commitments
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const fp = toHex(randomBytes(32));
|
|
const nonce = randomBytes(32);
|
|
const fields = { groupId: GID, epoch: 1, fingerprint: fp, nonce };
|
|
const commitment = await buildCommitment(subtle, fields);
|
|
|
|
assert.equal(commitment.length, GROUP_LIMITS.COMMIT_BYTES);
|
|
assert.equal(await verifyCommitment(subtle, commitment, fields), true);
|
|
|
|
// Every bound field is really bound.
|
|
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, epoch: 2 }), false, 'epoch is bound');
|
|
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, groupId: newGroupId() }), false, 'group id is bound');
|
|
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, fingerprint: toHex(randomBytes(32)) }), false, 'member is bound');
|
|
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, nonce: randomBytes(32) }), false, 'nonce is bound');
|
|
|
|
// Malformed input returns false rather than throwing into the caller.
|
|
assert.equal(await verifyCommitment(subtle, new Uint8Array(4), fields), false);
|
|
await assert.rejects(() => buildCommitment(subtle, { ...fields, nonce: randomBytes(8) }), /nonce must be 32 bytes/);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the ordering gate — the reason seven digits is enough
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const [a, b, c] = await Promise.all([
|
|
generateGroupIdentity(subtle), generateGroupIdentity(subtle), generateGroupIdentity(subtle),
|
|
]);
|
|
const members = [a.fingerprint, b.fingerprint, c.fingerprint];
|
|
const ceremony = new GroupSasCeremony({
|
|
groupId: GID, epoch: 1, selfFingerprint: a.fingerprint, memberFingerprints: members,
|
|
});
|
|
|
|
await ceremony.ownCommitment(subtle);
|
|
assert.equal(ceremony.commitmentsComplete, false);
|
|
|
|
// THE gate: no nonce leaves this device while a commitment is outstanding.
|
|
assert.throws(() => ceremony.reveal(), /cannot reveal before every member has committed/);
|
|
|
|
const bCeremony = new GroupSasCeremony({
|
|
groupId: GID, epoch: 1, selfFingerprint: b.fingerprint, memberFingerprints: members,
|
|
});
|
|
ceremony.acceptCommitment(b.fingerprint, await bCeremony.ownCommitment(subtle));
|
|
assert.throws(() => ceremony.reveal(), /cannot reveal/, 'two of three is still not all');
|
|
|
|
const cCeremony = new GroupSasCeremony({
|
|
groupId: GID, epoch: 1, selfFingerprint: c.fingerprint, memberFingerprints: members,
|
|
});
|
|
ceremony.acceptCommitment(c.fingerprint, await cCeremony.ownCommitment(subtle));
|
|
assert.equal(ceremony.commitmentsComplete, true);
|
|
assert.doesNotThrow(() => ceremony.reveal(), 'a complete commitment round unlocks the reveal');
|
|
|
|
// A member may not move after committing.
|
|
const other = new GroupSasCeremony({
|
|
groupId: GID, epoch: 1, selfFingerprint: b.fingerprint, memberFingerprints: members,
|
|
});
|
|
assert.throws(
|
|
() => ceremony.acceptCommitment(b.fingerprint, new Uint8Array(32).fill(9)),
|
|
/member changed their commitment/,
|
|
);
|
|
void other;
|
|
|
|
// Outsiders are refused outright.
|
|
assert.throws(
|
|
() => ceremony.acceptCommitment(toHex(randomBytes(32)), new Uint8Array(32)),
|
|
/commitment from a non-member/,
|
|
);
|
|
|
|
// A nonce that does not open its commitment fails the ceremony.
|
|
await assert.rejects(
|
|
() => ceremony.acceptReveal(subtle, b.fingerprint, randomBytes(32)),
|
|
/does not match the commitment/,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// an honest group converges on one code
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Run a full commit -> reveal -> finish round between n honest members. */
|
|
async function honestCeremony(identities, { groupId = GID, epoch = 1 } = {}) {
|
|
const members = identities.map((i) => i.fingerprint);
|
|
const ceremonies = identities.map((i) => new GroupSasCeremony({
|
|
groupId, epoch, selfFingerprint: i.fingerprint, memberFingerprints: members,
|
|
}));
|
|
|
|
const commitments = [];
|
|
for (const c of ceremonies) commitments.push(await c.ownCommitment(subtle));
|
|
for (let i = 0; i < ceremonies.length; i++) {
|
|
for (let j = 0; j < ceremonies.length; j++) {
|
|
if (i !== j) ceremonies[i].acceptCommitment(members[j], commitments[j]);
|
|
}
|
|
}
|
|
|
|
const nonces = ceremonies.map((c) => c.reveal());
|
|
for (let i = 0; i < ceremonies.length; i++) {
|
|
for (let j = 0; j < ceremonies.length; j++) {
|
|
if (i !== j) await ceremonies[i].acceptReveal(subtle, members[j], nonces[j]);
|
|
}
|
|
}
|
|
return Promise.all(ceremonies.map((c) => c.finish(subtle)));
|
|
}
|
|
|
|
{
|
|
const identities = await Promise.all(
|
|
Array.from({ length: 5 }, () => generateGroupIdentity(subtle)),
|
|
);
|
|
const codes = await honestCeremony(identities);
|
|
assert.equal(new Set(codes).size, 1, 'every honest member must read the same digits');
|
|
assert.match(codes[0], /^\d{7}$/, 'the group code is seven digits, like the pairwise SAS');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// a man in the middle produces a MISMATCH — which is the whole point
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// Bob and Carol are introduced by Mallory, who presents a different key to
|
|
// each of them. Neither can detect that from their own view alone; the group
|
|
// code is what differs when they compare it out loud.
|
|
const bob = await generateGroupIdentity(subtle);
|
|
const carol = await generateGroupIdentity(subtle);
|
|
const malloryToBob = await generateGroupIdentity(subtle);
|
|
const malloryToCarol = await generateGroupIdentity(subtle);
|
|
|
|
const bobsView = await honestCeremony([bob, carol, malloryToBob]);
|
|
const carolsView = await honestCeremony([bob, carol, malloryToCarol]);
|
|
|
|
assert.notEqual(
|
|
bobsView[0], carolsView[0],
|
|
'substituted key material must change the digits the victims read',
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the code does not depend on the order members were listed in
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const fps = Array.from({ length: 4 }, () => toHex(randomBytes(32)));
|
|
const contributions = fps.map((fingerprint) => ({ fingerprint, nonce: randomBytes(32) }));
|
|
|
|
const forward = await computeGroupSas(subtle, { groupId: GID, epoch: 3, contributions });
|
|
const reversed = await computeGroupSas(subtle, { groupId: GID, epoch: 3, contributions: [...contributions].reverse() });
|
|
assert.equal(forward, reversed, 'member ordering must not change the code');
|
|
|
|
// But the epoch and the group do.
|
|
const nextEpoch = await computeGroupSas(subtle, { groupId: GID, epoch: 4, contributions });
|
|
assert.notEqual(forward, nextEpoch, 'a new epoch must produce a new code');
|
|
const otherGroup = await computeGroupSas(subtle, { groupId: newGroupId(), epoch: 3, contributions });
|
|
assert.notEqual(forward, otherGroup, 'the code is bound to the group');
|
|
|
|
// canonicalFingerprints is where the ordering and the limits are enforced.
|
|
assert.deepEqual(canonicalFingerprints([...fps].reverse()), [...fps].sort());
|
|
assert.throws(() => canonicalFingerprints([fps[0], fps[0]]), /duplicate member/);
|
|
assert.throws(() => canonicalFingerprints([fps[0]]), /at least two members/);
|
|
assert.throws(
|
|
() => canonicalFingerprints(Array.from({ length: 9 }, () => toHex(randomBytes(32)))),
|
|
/limited to 8 members/,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// membership operations
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const admin = await generateGroupIdentity(subtle);
|
|
const bob = await generateGroupIdentity(subtle);
|
|
const carol = await generateGroupIdentity(subtle);
|
|
const { publicKey: adminKey } = await importMemberIdentity(subtle, admin.spki);
|
|
|
|
const fields = {
|
|
groupId: GID, epoch: 2, op: MEMBER_OPS.ADD,
|
|
memberFps: [admin.fingerprint, bob.fingerprint, carol.fingerprint],
|
|
name: 'Field team',
|
|
};
|
|
const sig = await signMemberOp(subtle, admin.keyPair.privateKey, fields);
|
|
assert.equal(await verifyMemberOp(subtle, adminKey, fields, sig), true);
|
|
|
|
// Every signed field is bound.
|
|
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, epoch: 3 }, sig), false, 'epoch is signed');
|
|
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, op: MEMBER_OPS.REMOVE }, sig), false, 'the operation is signed');
|
|
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, name: 'Field teams' }, sig), false, 'the name is signed');
|
|
assert.equal(
|
|
await verifyMemberOp(subtle, adminKey, { ...fields, memberFps: [admin.fingerprint, bob.fingerprint] }, sig),
|
|
false, 'dropping a member invalidates the operation',
|
|
);
|
|
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, groupId: newGroupId() }, sig), false, 'the group is signed');
|
|
|
|
// Someone else's key does not verify the admin's operation.
|
|
const { publicKey: bobKey } = await importMemberIdentity(subtle, bob.spki);
|
|
assert.equal(await verifyMemberOp(subtle, bobKey, fields, sig), false, 'only the admin can author membership');
|
|
|
|
// Reordering the member list is NOT a different operation — canonical order.
|
|
const shuffled = { ...fields, memberFps: [carol.fingerprint, admin.fingerprint, bob.fingerprint] };
|
|
assert.equal(await verifyMemberOp(subtle, adminKey, shuffled, sig), true, 'member order is canonicalised before signing');
|
|
|
|
// Malformed signatures are rejected without throwing.
|
|
assert.equal(await verifyMemberOp(subtle, adminKey, fields, new Uint8Array(4)), false);
|
|
assert.equal(await verifyMemberOp(subtle, adminKey, fields, 'not bytes'), false);
|
|
|
|
// Length-prefixed encoding: no two field sets can collide.
|
|
const a = memberOpPayload({ ...fields, name: 'ab' });
|
|
const b = memberOpPayload({ ...fields, name: 'a' });
|
|
assert.notEqual(toHex(a), toHex(b));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// group message signatures
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const sender = await generateGroupIdentity(subtle);
|
|
const { publicKey } = await importMemberIdentity(subtle, sender.spki);
|
|
|
|
const body = 'meet at the usual place';
|
|
const bodyHash = await hashBody(subtle, body);
|
|
const fields = { groupId: GID, epoch: 1, seq: 7, senderFp: sender.fingerprint, bodyHash };
|
|
const sig = await signGroupMessage(subtle, sender.keyPair.privateKey, fields);
|
|
|
|
assert.equal(await verifyGroupMessage(subtle, publicKey, fields, sig), true);
|
|
|
|
// A different body under the same signature is what a tampering relay would
|
|
// have to produce, and it does not verify.
|
|
const otherHash = await hashBody(subtle, 'meet at the other place');
|
|
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, bodyHash: otherHash }, sig), false);
|
|
|
|
// Replaying one message under another sequence number or epoch fails too.
|
|
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, seq: 8 }, sig), false, 'seq is signed');
|
|
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, epoch: 2 }, sig), false, 'epoch is signed');
|
|
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, senderFp: toHex(randomBytes(32)) }, sig), false);
|
|
|
|
// Oversized bodies are refused before they are hashed.
|
|
await assert.rejects(
|
|
() => hashBody(subtle, 'x'.repeat(GROUP_LIMITS.MAX_BODY_BYTES + 1)),
|
|
/exceeds the group limit/,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// codecs bound their input
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const bytes = randomBytes(48);
|
|
assert.equal(toHex(fromHex(toHex(bytes))), toHex(bytes));
|
|
assert.deepEqual(fromB64(toB64(bytes)), bytes);
|
|
|
|
assert.throws(() => fromHex('zz'), /not a hex string/);
|
|
assert.throws(() => fromHex('abc'), /not a hex string/);
|
|
// The base64 bound is applied before decoding, so a huge string cannot force
|
|
// a huge allocation.
|
|
assert.throws(() => fromB64('A'.repeat(100000)), /exceeds its limit/);
|
|
assert.throws(() => fromB64('!!!!', { max: 64 }), /malformed base64/);
|
|
}
|
|
|
|
console.log('group-crypto.test.mjs: all assertions passed');
|