feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
|
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
|
|
|
|
import {
|
|
|
|
|
initSenderSession,
|
|
|
|
|
initReceiverSession,
|
|
|
|
|
ratchetEncrypt,
|
|
|
|
|
ratchetDecrypt,
|
|
|
|
|
MaxSkipExceededError,
|
|
|
|
|
DecryptionError,
|
|
|
|
|
} from '../src/index.js';
|
|
|
|
|
import type { SessionState, RatchetMessage } from '../src/index.js';
|
|
|
|
|
|
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
|
const enc = new TextEncoder();
|
|
|
|
|
const dec = new TextDecoder();
|
|
|
|
|
|
|
|
|
|
/** Helper: set up Alice (sender) and Bob (receiver) sessions from a shared root key */
|
|
|
|
|
async function setupPair(): Promise<{ alice: SessionState; bob: SessionState }> {
|
|
|
|
|
const rootKey = crypto.randomBytes(32);
|
|
|
|
|
const remoteIdentityKey = crypto.randomBytes(32);
|
|
|
|
|
|
|
|
|
|
// Bob's initial DH keypair (would be his signed prekey in real X3DH)
|
|
|
|
|
const bobDH = await crypto.generateX25519KeyPair();
|
|
|
|
|
|
|
|
|
|
const alice = await initSenderSession(crypto, rootKey, remoteIdentityKey, bobDH.publicKey);
|
|
|
|
|
const bob = initReceiverSession(rootKey, remoteIdentityKey, bobDH);
|
|
|
|
|
|
|
|
|
|
return { alice, bob };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('Double Ratchet', () => {
|
|
|
|
|
// ─── Basic Send/Receive ──────────────────────────────────
|
|
|
|
|
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
describe('initReceiverSession isolation', () => {
|
|
|
|
|
/**
|
|
|
|
|
* Regression — the V3.10 multi-sender recovery flow surfaced a
|
|
|
|
|
* bug where `initReceiverSession` shared a reference to the
|
|
|
|
|
* receiver's signed-prekey keypair with the new session. The
|
|
|
|
|
* first DH ratchet step zeroed the session's stale send-key
|
|
|
|
|
* private bytes — which were the SAME backing buffer as the
|
|
|
|
|
* persisted signed prekey. A subsequent X3DH from a different
|
|
|
|
|
* sender then derived a divergent root key and decryption
|
|
|
|
|
* failed.
|
|
|
|
|
*
|
|
|
|
|
* Fix: `initReceiverSession` copies the keypair into the
|
|
|
|
|
* session. Verify here.
|
|
|
|
|
*/
|
|
|
|
|
test('does not mutate the caller-provided keypair after a DH ratchet step', async () => {
|
|
|
|
|
const rootKey = crypto.randomBytes(32);
|
|
|
|
|
const remoteIdentityKey = crypto.randomBytes(32);
|
|
|
|
|
const bobDH = await crypto.generateX25519KeyPair();
|
|
|
|
|
const originalPrivate = new Uint8Array(bobDH.privateKey);
|
|
|
|
|
const originalPublic = new Uint8Array(bobDH.publicKey);
|
|
|
|
|
|
|
|
|
|
const bob = initReceiverSession(rootKey, remoteIdentityKey, bobDH);
|
|
|
|
|
|
|
|
|
|
// Drive a full receive that triggers `performDHRatchetStep`
|
|
|
|
|
// via a message with a fresh dhPublicKey.
|
|
|
|
|
const aliceFirst = await initSenderSession(crypto, rootKey, remoteIdentityKey, bobDH.publicKey);
|
|
|
|
|
const msg = await ratchetEncrypt(crypto, aliceFirst, enc.encode('hi'));
|
|
|
|
|
await ratchetDecrypt(crypto, bob, msg);
|
|
|
|
|
|
|
|
|
|
// The ORIGINAL keypair must not have been touched, so a
|
|
|
|
|
// second X3DH-style establishment using the same prekey
|
|
|
|
|
// material still succeeds.
|
|
|
|
|
expect(Array.from(bobDH.privateKey)).toEqual(Array.from(originalPrivate));
|
|
|
|
|
expect(Array.from(bobDH.publicKey)).toEqual(Array.from(originalPublic));
|
|
|
|
|
|
|
|
|
|
// Sanity-check: a second receiver session built from the
|
|
|
|
|
// same keypair should still decrypt fresh sender traffic.
|
|
|
|
|
const bob2 = initReceiverSession(rootKey, remoteIdentityKey, bobDH);
|
|
|
|
|
const aliceSecond = await initSenderSession(crypto, rootKey, remoteIdentityKey, bobDH.publicKey);
|
|
|
|
|
const msg2 = await ratchetEncrypt(crypto, aliceSecond, enc.encode('hi again'));
|
|
|
|
|
const plain2 = await ratchetDecrypt(crypto, bob2, msg2);
|
|
|
|
|
expect(dec.decode(plain2)).toBe('hi again');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
describe('basic send/receive', () => {
|
|
|
|
|
test('Alice encrypts, Bob decrypts', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
const msg = await ratchetEncrypt(crypto, alice, enc.encode('hello bob'));
|
|
|
|
|
const plaintext = await ratchetDecrypt(crypto, bob, msg);
|
|
|
|
|
|
|
|
|
|
expect(dec.decode(plaintext)).toBe('hello bob');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('multiple messages in same direction', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
const messages = ['first', 'second', 'third'];
|
|
|
|
|
const encrypted: RatchetMessage[] = [];
|
|
|
|
|
|
|
|
|
|
for (const text of messages) {
|
|
|
|
|
encrypted.push(await ratchetEncrypt(crypto, alice, enc.encode(text)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < messages.length; i++) {
|
|
|
|
|
const plaintext = await ratchetDecrypt(crypto, bob, encrypted[i]);
|
|
|
|
|
expect(dec.decode(plaintext)).toBe(messages[i]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('counter increments with each message', async () => {
|
|
|
|
|
const { alice } = await setupPair();
|
|
|
|
|
|
|
|
|
|
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
|
|
|
|
|
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('b'));
|
|
|
|
|
const m2 = await ratchetEncrypt(crypto, alice, enc.encode('c'));
|
|
|
|
|
|
|
|
|
|
expect(m0.counter).toBe(0);
|
|
|
|
|
expect(m1.counter).toBe(1);
|
|
|
|
|
expect(m2.counter).toBe(2);
|
|
|
|
|
|
|
|
|
|
// All use the same DH key (no ratchet step yet)
|
|
|
|
|
expect(m0.dhPublicKey).toEqual(m1.dhPublicKey);
|
|
|
|
|
expect(m1.dhPublicKey).toEqual(m2.dhPublicKey);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('each message has a unique nonce', async () => {
|
|
|
|
|
const { alice } = await setupPair();
|
|
|
|
|
|
|
|
|
|
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
|
|
|
|
|
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('a'));
|
|
|
|
|
|
|
|
|
|
expect(m0.nonce).not.toEqual(m1.nonce);
|
|
|
|
|
expect(m0.ciphertext).not.toEqual(m1.ciphertext);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Ping-Pong (DH Ratchet) ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe('ping-pong conversation', () => {
|
|
|
|
|
test('alternating messages trigger DH ratchets', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
// Alice → Bob
|
|
|
|
|
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('hi bob'));
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('hi bob');
|
|
|
|
|
|
|
|
|
|
// Bob → Alice (new DH key)
|
|
|
|
|
const m2 = await ratchetEncrypt(crypto, bob, enc.encode('hi alice'));
|
|
|
|
|
expect(m2.dhPublicKey).not.toEqual(m1.dhPublicKey); // DH ratchet happened
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, alice, m2))).toBe('hi alice');
|
|
|
|
|
|
|
|
|
|
// Alice → Bob (another new DH key)
|
|
|
|
|
const m3 = await ratchetEncrypt(crypto, alice, enc.encode('how are you'));
|
|
|
|
|
expect(m3.dhPublicKey).not.toEqual(m1.dhPublicKey);
|
|
|
|
|
expect(m3.dhPublicKey).not.toEqual(m2.dhPublicKey);
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m3))).toBe('how are you');
|
|
|
|
|
|
|
|
|
|
// Bob → Alice
|
|
|
|
|
const m4 = await ratchetEncrypt(crypto, bob, enc.encode('great!'));
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, alice, m4))).toBe('great!');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('extended conversation with many turns', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
|
|
|
const sender = i % 2 === 0 ? alice : bob;
|
|
|
|
|
const receiver = i % 2 === 0 ? bob : alice;
|
|
|
|
|
const text = `message ${i}`;
|
|
|
|
|
|
|
|
|
|
const msg = await ratchetEncrypt(crypto, sender, enc.encode(text));
|
|
|
|
|
const plain = await ratchetDecrypt(crypto, receiver, msg);
|
|
|
|
|
expect(dec.decode(plain)).toBe(text);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('burst messages then reply', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
// Alice sends 5 messages
|
|
|
|
|
const burst: RatchetMessage[] = [];
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
burst.push(await ratchetEncrypt(crypto, alice, enc.encode(`alice-${i}`)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bob receives all 5
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, burst[i]))).toBe(`alice-${i}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bob replies (triggers DH ratchet)
|
|
|
|
|
const reply = await ratchetEncrypt(crypto, bob, enc.encode('got them all'));
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, alice, reply))).toBe('got them all');
|
|
|
|
|
|
|
|
|
|
// Alice sends more
|
|
|
|
|
const m = await ratchetEncrypt(crypto, alice, enc.encode('great!'));
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m))).toBe('great!');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Out-of-Order Messages ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe('out-of-order delivery', () => {
|
|
|
|
|
test('messages received in reverse order', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('first'));
|
|
|
|
|
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('second'));
|
|
|
|
|
const m2 = await ratchetEncrypt(crypto, alice, enc.encode('third'));
|
|
|
|
|
|
|
|
|
|
// Deliver in reverse
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m2))).toBe('third');
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m0))).toBe('first');
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('second');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('skip some messages, then receive them later', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
const messages: RatchetMessage[] = [];
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
messages.push(await ratchetEncrypt(crypto, alice, enc.encode(`msg-${i}`)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Receive only even-numbered messages first
|
|
|
|
|
for (let i = 0; i < 10; i += 2) {
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, messages[i]))).toBe(`msg-${i}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Then receive odd-numbered (skipped) messages
|
|
|
|
|
for (let i = 1; i < 10; i += 2) {
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, messages[i]))).toBe(`msg-${i}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('out-of-order across DH ratchet boundaries', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
// Alice sends 3 messages
|
|
|
|
|
const a0 = await ratchetEncrypt(crypto, alice, enc.encode('a0'));
|
|
|
|
|
const a1 = await ratchetEncrypt(crypto, alice, enc.encode('a1'));
|
|
|
|
|
const a2 = await ratchetEncrypt(crypto, alice, enc.encode('a2'));
|
|
|
|
|
|
|
|
|
|
// Bob receives only a2 (skips a0, a1)
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, a2))).toBe('a2');
|
|
|
|
|
|
|
|
|
|
// Bob replies (DH ratchet)
|
|
|
|
|
const b0 = await ratchetEncrypt(crypto, bob, enc.encode('b0'));
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, alice, b0))).toBe('b0');
|
|
|
|
|
|
|
|
|
|
// Now Bob receives the skipped a0 and a1 (from the old chain)
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, a0))).toBe('a0');
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, a1))).toBe('a1');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Error Cases ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe('error cases', () => {
|
|
|
|
|
test('max skip exceeded throws', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
// Encrypt 1002 messages but only try to decrypt the last one
|
|
|
|
|
let lastMsg: RatchetMessage | undefined;
|
|
|
|
|
for (let i = 0; i < 1002; i++) {
|
|
|
|
|
lastMsg = await ratchetEncrypt(crypto, alice, enc.encode(`msg-${i}`));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(ratchetDecrypt(crypto, bob, lastMsg!)).rejects.toThrow(MaxSkipExceededError);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tampered ciphertext fails', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
const msg = await ratchetEncrypt(crypto, alice, enc.encode('secret'));
|
|
|
|
|
msg.ciphertext[0] ^= 0xff;
|
|
|
|
|
|
|
|
|
|
expect(ratchetDecrypt(crypto, bob, msg)).rejects.toThrow(DecryptionError);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tampered header (counter) fails due to AAD', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
const msg = await ratchetEncrypt(crypto, alice, enc.encode('secret'));
|
|
|
|
|
msg.counter = 999; // tamper with counter
|
|
|
|
|
|
|
|
|
|
expect(ratchetDecrypt(crypto, bob, msg)).rejects.toThrow();
|
|
|
|
|
});
|
2026-05-04 22:58:26 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Regression — the v4.2.0 OutboundQueue waiter-since bug delivered
|
|
|
|
|
* the same envelope twice to `manager.decrypt`. The first decrypt
|
|
|
|
|
* succeeded via a cached skipped key; the second one fell into the
|
|
|
|
|
* `message.counter < chain.counter` path with no skipped key
|
|
|
|
|
* available, advanced the chainKey ONCE and rewound `chain.counter`
|
|
|
|
|
* to `message.counter + 1`, leaving the ratchet permanently
|
|
|
|
|
* desynced. ratchetDecrypt now rejects without mutating state when
|
|
|
|
|
* a same-DH message is behind the chain and not in skippedKeys, so
|
|
|
|
|
* a downstream replay (transport bug, retry, etc.) cannot poison
|
|
|
|
|
* the session for everyone else.
|
|
|
|
|
*/
|
|
|
|
|
test('same-DH stale message after consumed skipped key fails without corrupting state', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
// Alice sends 3 messages on the same DH chain.
|
|
|
|
|
const m0 = await ratchetEncrypt(crypto, alice, enc.encode('m0'));
|
|
|
|
|
const m1 = await ratchetEncrypt(crypto, alice, enc.encode('m1'));
|
|
|
|
|
const m2 = await ratchetEncrypt(crypto, alice, enc.encode('m2'));
|
|
|
|
|
|
|
|
|
|
// Bob receives m1 first, caching m0's key. Then m0 (delivered
|
|
|
|
|
// via the cache). After this, m0's skipped key is consumed.
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('m1');
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m0))).toBe('m0');
|
|
|
|
|
|
|
|
|
|
// Replay of m0: skippedKey is gone, chain.counter is past m0.
|
|
|
|
|
// Pre-fix: this would corrupt Bob's chain state; post-fix it
|
|
|
|
|
// throws cleanly.
|
|
|
|
|
await expect(ratchetDecrypt(crypto, bob, m0)).rejects.toThrow(DecryptionError);
|
|
|
|
|
|
|
|
|
|
// Bob can still decrypt the remaining valid message — chain
|
|
|
|
|
// state was NOT mutated by the rejected replay.
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, m2))).toBe('m2');
|
|
|
|
|
});
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Long Conversation ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe('stress test', () => {
|
|
|
|
|
test('100+ message conversation with alternating turns', async () => {
|
|
|
|
|
const { alice, bob } = await setupPair();
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
|
|
|
// Alice sends 2 messages
|
|
|
|
|
for (let j = 0; j < 2; j++) {
|
|
|
|
|
const text = `alice-${i}-${j}`;
|
|
|
|
|
const msg = await ratchetEncrypt(crypto, alice, enc.encode(text));
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bob, msg))).toBe(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bob sends 1 message
|
|
|
|
|
const text = `bob-${i}`;
|
|
|
|
|
const msg = await ratchetEncrypt(crypto, bob, enc.encode(text));
|
|
|
|
|
expect(dec.decode(await ratchetDecrypt(crypto, alice, msg))).toBe(text);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|