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');
|