mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 00:54:56 +00:00
297 lines
13 KiB
JavaScript
297 lines
13 KiB
JavaScript
/**
|
|
* Group DM crypto: property tests.
|
|
*
|
|
* Run: node --test test/e2e-group.test.js
|
|
*
|
|
* These assert the group DM security claims, and most of them are
|
|
* written as attacks: a test that only shows the happy path would pass just as
|
|
* well with the signature check deleted.
|
|
*/
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const { webcrypto } = require('node:crypto');
|
|
const G = require('../public/js/e2e-group.js');
|
|
|
|
const subtle = webcrypto.subtle;
|
|
|
|
/* Stand-in for the pairwise ECDH secret two members already share. The real
|
|
derivation lives in e2e.js; what matters here is that wrapping uses a key
|
|
only those two hold. */
|
|
async function pairKey(a, b) {
|
|
const bits = await subtle.deriveBits({ name: 'ECDH', public: b.publicKey }, a.privateKey, 256);
|
|
const hkdf = await subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
|
return subtle.deriveKey(
|
|
{ name: 'HKDF', hash: 'SHA-256', salt: Buffer.from('haven-e2e-dm-v1'), info: Buffer.from('aes-gcm-key') },
|
|
hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
async function makeMember(id) {
|
|
const ecdh = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
const sign = await G.generateSigningKeyPair();
|
|
return { id, ecdh, sign, signJwk: await G.exportPublicJwk(sign.publicKey), ecdhJwk: await G.exportPublicJwk(ecdh.publicKey) };
|
|
}
|
|
|
|
const CHANNEL = 42;
|
|
|
|
test('a member decrypts a message and the signature verifies', async () => {
|
|
const [alice, bob] = [await makeMember(1), await makeMember(2)];
|
|
const K = await G.generateEpochKey();
|
|
const wrapped = await G.wrapEpochKey(K, await pairKey(alice.ecdh, bob.ecdh));
|
|
const bobKey = await G.unwrapEpochKey(wrapped, await pairKey(bob.ecdh, alice.ecdh));
|
|
|
|
const env = await G.encryptGroupMessage('the eagle lands at noon', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
|
|
const r = await G.decryptGroupMessage(env, {
|
|
epochKey: bobKey, channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, true);
|
|
assert.strictEqual(r.plaintext, 'the eagle lands at noon');
|
|
});
|
|
|
|
test('the server cannot read the ciphertext', async () => {
|
|
const alice = await makeMember(1);
|
|
const K = await G.generateEpochKey();
|
|
const env = await G.encryptGroupMessage('salary figures', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
const stored = JSON.stringify(env);
|
|
assert.ok(!stored.includes('salary'), 'plaintext must not appear in what the server stores');
|
|
});
|
|
|
|
/* ── forgery ──────────────────────────────────────── */
|
|
|
|
test('a member cannot forge a message as another member', async () => {
|
|
// Mallory holds the epoch key (she is in the group), so she can encrypt
|
|
// perfectly well. Without Alice's signing key she still cannot author as her.
|
|
const [alice, mallory] = [await makeMember(1), await makeMember(3)];
|
|
const K = await G.generateEpochKey();
|
|
|
|
const forged = await G.encryptGroupMessage('transfer the money', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: mallory.sign.privateKey,
|
|
});
|
|
|
|
const r = await G.decryptGroupMessage(forged, {
|
|
epochKey: K, channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, false);
|
|
assert.strictEqual(r.reason, 'bad-signature');
|
|
assert.strictEqual(r.plaintext, null, 'a rejected message must not surface plaintext');
|
|
});
|
|
|
|
test('re-attributing a genuine message to another sender fails', async () => {
|
|
const [alice, bob] = [await makeMember(1), await makeMember(2)];
|
|
const K = await G.generateEpochKey();
|
|
const env = await G.encryptGroupMessage('I approve', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
// The server relabels the row as Bob's and serves Bob's signing key with it.
|
|
const r = await G.decryptGroupMessage(env, {
|
|
epochKey: K, channelId: CHANNEL, senderId: bob.id, signingPublicJwk: bob.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, false);
|
|
});
|
|
|
|
test('a tampered ciphertext is rejected', async () => {
|
|
const alice = await makeMember(1);
|
|
const K = await G.generateEpochKey();
|
|
const env = await G.encryptGroupMessage('original', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
const bytes = G._unb64(env.ct);
|
|
bytes[0] ^= 0xff;
|
|
const r = await G.decryptGroupMessage({ ...env, ct: G._b64(bytes) }, {
|
|
epochKey: K, channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, false);
|
|
assert.strictEqual(r.reason, 'bad-signature');
|
|
});
|
|
|
|
test('an unsigned message is rejected outright', async () => {
|
|
const alice = await makeMember(1);
|
|
const K = await G.generateEpochKey();
|
|
const env = await G.encryptGroupMessage('hello', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
delete env.sig;
|
|
const r = await G.decryptGroupMessage(env, {
|
|
epochKey: K, channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, false);
|
|
assert.strictEqual(r.reason, 'unsigned');
|
|
});
|
|
|
|
/* ── replay across contexts ───────────────────────── */
|
|
|
|
test('a message cannot be replayed into another channel', async () => {
|
|
const alice = await makeMember(1);
|
|
const K = await G.generateEpochKey();
|
|
const env = await G.encryptGroupMessage('context matters', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
const r = await G.decryptGroupMessage(env, {
|
|
epochKey: K, channelId: 999, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, false, 'signature binds the channel');
|
|
});
|
|
|
|
test('a message cannot be replayed into another epoch', async () => {
|
|
const alice = await makeMember(1);
|
|
const K = await G.generateEpochKey();
|
|
const env = await G.encryptGroupMessage('epoch bound', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
const r = await G.decryptGroupMessage({ ...env, e: 2 }, {
|
|
epochKey: K, channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, false, 'signature binds the epoch');
|
|
});
|
|
|
|
/* ── epoch isolation ──────────────────────────────── */
|
|
|
|
test('a member added at epoch 2 cannot read epoch 1', async () => {
|
|
const alice = await makeMember(1);
|
|
const K1 = await G.generateEpochKey();
|
|
const K2 = await G.generateEpochKey();
|
|
const old = await G.encryptGroupMessage('said before you joined', {
|
|
epochKey: K1, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
// The newcomer holds K2 only; K1 was never wrapped for them.
|
|
const r = await G.decryptGroupMessage(old, {
|
|
epochKey: K2, channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, false);
|
|
assert.strictEqual(r.reason, 'wrong-epoch-key', 'signature is valid; the key is not held');
|
|
});
|
|
|
|
test('a removed member cannot read the next epoch', async () => {
|
|
const alice = await makeMember(1);
|
|
const K1 = await G.generateEpochKey();
|
|
const K2 = await G.generateEpochKey();
|
|
const after = await G.encryptGroupMessage('said after you left', {
|
|
epochKey: K2, epoch: 2, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
const r = await G.decryptGroupMessage(after, {
|
|
epochKey: K1, channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, false);
|
|
});
|
|
|
|
test('a non-member cannot unwrap an epoch key not wrapped for them', async () => {
|
|
const [alice, bob, eve] = [await makeMember(1), await makeMember(2), await makeMember(4)];
|
|
const K = await G.generateEpochKey();
|
|
const forBob = await G.wrapEpochKey(K, await pairKey(alice.ecdh, bob.ecdh));
|
|
await assert.rejects(() => G.unwrapEpochKey(forBob, pairKey(eve.ecdh, alice.ecdh).then((k) => k)));
|
|
});
|
|
|
|
/* ── scrollback: the regression a ratchet would cause ── */
|
|
|
|
test('history still decrypts after several epoch rotations', async () => {
|
|
const alice = await makeMember(1);
|
|
const keys = [await G.generateEpochKey(), await G.generateEpochKey(), await G.generateEpochKey()];
|
|
const sent = [];
|
|
for (let e = 0; e < 3; e++) {
|
|
sent.push(await G.encryptGroupMessage(`message in epoch ${e + 1}`, {
|
|
epochKey: keys[e], epoch: e + 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
}));
|
|
}
|
|
// A long-standing member retains every epoch key they were given, so
|
|
// scrolling back keeps working. This is the property a per-message ratchet
|
|
// would have destroyed.
|
|
for (let e = 0; e < 3; e++) {
|
|
const r = await G.decryptGroupMessage(sent[e], {
|
|
epochKey: keys[e], channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(r.ok, true);
|
|
assert.strictEqual(r.plaintext, `message in epoch ${e + 1}`);
|
|
}
|
|
});
|
|
|
|
/* ── transcript chaining ──────────────────────────── */
|
|
|
|
test('a withheld message is detected as a chain break', async () => {
|
|
const alice = await makeMember(1);
|
|
const K = await G.generateEpochKey();
|
|
const mk = async (text, prev) => G.encryptGroupMessage(text, {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
|
|
const m1 = await mk('one', null);
|
|
const h1 = await G.envelopeHash(m1);
|
|
const m2 = await mk('two', h1);
|
|
const h2 = await G.envelopeHash(m2);
|
|
const m3 = await mk('three', h2);
|
|
|
|
const full = G.verifyChain([h1, h2], [m1, m2, m3]);
|
|
assert.strictEqual(full.ok, true, 'an intact transcript raises nothing');
|
|
|
|
// The server withholds m2 from this member. m3 still verifies on its own,
|
|
// which is exactly why signatures alone are not enough.
|
|
const solo = await G.decryptGroupMessage(m3, {
|
|
epochKey: K, channelId: CHANNEL, senderId: alice.id, signingPublicJwk: alice.signJwk,
|
|
});
|
|
assert.strictEqual(solo.ok, true, 'the withheld-from view still sees a valid signature');
|
|
|
|
const gapped = G.verifyChain([h1], [m1, m3]);
|
|
assert.strictEqual(gapped.ok, false, 'but the chain shows the gap');
|
|
assert.strictEqual(gapped.breaks[0].missingPrev, h2);
|
|
});
|
|
|
|
test('concurrent sends sharing a prev do not raise a false alarm', async () => {
|
|
const [alice, bob] = [await makeMember(1), await makeMember(2)];
|
|
const K = await G.generateEpochKey();
|
|
const root = await G.encryptGroupMessage('root', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: null,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
const h = await G.envelopeHash(root);
|
|
// Both reply before seeing each other: a legitimate fork, not tampering.
|
|
const a = await G.encryptGroupMessage('alice replies', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: alice.id, prev: h,
|
|
signingPrivateKey: alice.sign.privateKey,
|
|
});
|
|
const b = await G.encryptGroupMessage('bob replies', {
|
|
epochKey: K, epoch: 1, channelId: CHANNEL, senderId: bob.id, prev: h,
|
|
signingPrivateKey: bob.sign.privateKey,
|
|
});
|
|
assert.strictEqual(G.verifyChain([h], [root, a, b]).ok, true);
|
|
});
|
|
|
|
/* ── roster gossip ────────────────────────────────── */
|
|
|
|
test('members agree on the roster digest, and disagree under substitution', async () => {
|
|
const [alice, bob, carol] = [await makeMember(1), await makeMember(2), await makeMember(3)];
|
|
const roster = [alice, bob, carol].map((m) => ({ id: m.id, ecdhJwk: m.ecdhJwk, signJwk: m.signJwk }));
|
|
|
|
const d1 = await G.rosterDigest(roster);
|
|
const d2 = await G.rosterDigest([...roster].reverse()); // order must not matter
|
|
assert.strictEqual(d1, d2, 'digest is canonical regardless of member order');
|
|
|
|
// The server feeds Carol a substituted key for Bob.
|
|
const attacker = await makeMember(99);
|
|
const carolsView = roster.map((m) => (m.id === bob.id ? { ...m, ecdhJwk: attacker.ecdhJwk } : m));
|
|
assert.notStrictEqual(await G.rosterDigest(carolsView), d1, 'substitution changes the digest');
|
|
});
|
|
|
|
test('a substituted signing key also changes the digest', async () => {
|
|
const [alice, bob] = [await makeMember(1), await makeMember(2)];
|
|
const roster = [alice, bob].map((m) => ({ id: m.id, ecdhJwk: m.ecdhJwk, signJwk: m.signJwk }));
|
|
const attacker = await makeMember(99);
|
|
const tampered = roster.map((m) => (m.id === bob.id ? { ...m, signJwk: attacker.signJwk } : m));
|
|
assert.notStrictEqual(await G.rosterDigest(tampered), await G.rosterDigest(roster));
|
|
});
|