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>
This commit is contained in:
2026-04-09 20:08:19 +02:00
commit bd6452044f
27 changed files with 2517 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import {
generateIdentityKeyPair,
generateSignedPreKey,
generateOneTimePreKeys,
createPreKeyBundle,
processPreKeyBundle,
processPreKeyMessage,
initSenderSession,
initReceiverSession,
ratchetEncrypt,
ratchetDecrypt,
} from '../src/index.js';
const crypto = new SubtleCryptoProvider();
const enc = new TextEncoder();
const dec = new TextDecoder();
describe('Full E2EE Integration: X3DH → Double Ratchet', () => {
test('complete conversation between Alice and Bob', async () => {
// ─── Setup Bob (publishes prekey bundle) ─────────────────
const bobStorage = new MemoryStorage();
const bobIdentity = await generateIdentityKeyPair(crypto);
await bobStorage.saveIdentityKeyPair(bobIdentity);
await bobStorage.saveLocalRegistrationId(42);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 10);
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
// ─── Alice initiates (processes bundle, creates session) ──
const aliceStorage = new MemoryStorage();
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
// Alice initializes her ratchet session
const aliceSession = await initSenderSession(
crypto,
x3dhResult.rootKey,
x3dhResult.remoteIdentityKey,
x3dhResult.remoteSignedPreKey, // Bob's signed prekey = initial DH ratchet key
);
// Alice encrypts her first message
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('Hello Bob! This is E2EE.'));
// Alice sends a PreKeyMessage to Bob
const preKeyMessage = {
registrationId: 1,
preKeyId: x3dhResult.preKeyId,
signedPreKeyId: x3dhResult.signedPreKeyId,
ephemeralKey: x3dhResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: firstMsg,
};
// ─── Bob receives and processes ──────────────────────────
const bobX3dh = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
expect(bobX3dh.rootKey).toEqual(x3dhResult.rootKey);
// Bob initializes his ratchet session
const bobSession = initReceiverSession(
bobX3dh.rootKey,
bobX3dh.remoteIdentityKey,
bobSignedPreKey.keyPair, // Bob's signed prekey as his initial DH keypair
);
// Bob decrypts Alice's first message
const plaintext1 = await ratchetDecrypt(crypto, bobSession, bobX3dh.initialMessage);
expect(dec.decode(plaintext1)).toBe('Hello Bob! This is E2EE.');
// ─── Full conversation ───────────────────────────────────
// Alice sends more
const m2 = await ratchetEncrypt(crypto, aliceSession, enc.encode('Are you there?'));
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, m2))).toBe('Are you there?');
// Bob replies (DH ratchet triggers)
const m3 = await ratchetEncrypt(crypto, bobSession, enc.encode('Yes! Forward secrecy is active.'));
expect(dec.decode(await ratchetDecrypt(crypto, aliceSession, m3))).toBe('Yes! Forward secrecy is active.');
// Alice replies
const m4 = await ratchetEncrypt(crypto, aliceSession, enc.encode('Every message has a unique key.'));
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, m4))).toBe('Every message has a unique key.');
// Multiple back-and-forth
for (let i = 0; i < 10; i++) {
const sender = i % 2 === 0 ? aliceSession : bobSession;
const receiver = i % 2 === 0 ? bobSession : aliceSession;
const text = `Turn ${i}: ${i % 2 === 0 ? 'Alice' : 'Bob'} speaking`;
const msg = await ratchetEncrypt(crypto, sender, enc.encode(text));
expect(dec.decode(await ratchetDecrypt(crypto, receiver, msg))).toBe(text);
}
});
test('works without one-time prekey', async () => {
const bobStorage = new MemoryStorage();
const bobIdentity = await generateIdentityKeyPair(crypto);
await bobStorage.saveIdentityKeyPair(bobIdentity);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
// No one-time prekeys
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
const aliceStorage = new MemoryStorage();
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
const aliceSession = await initSenderSession(
crypto, x3dhResult.rootKey, x3dhResult.remoteIdentityKey, x3dhResult.remoteSignedPreKey,
);
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('No OTPK needed'));
const preKeyMessage = {
registrationId: 1,
signedPreKeyId: x3dhResult.signedPreKeyId,
ephemeralKey: x3dhResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: firstMsg,
};
const bobX3dh = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
const bobSession = initReceiverSession(
bobX3dh.rootKey, bobX3dh.remoteIdentityKey, bobSignedPreKey.keyPair,
);
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, bobX3dh.initialMessage)))
.toBe('No OTPK needed');
// Continue conversation
const reply = await ratchetEncrypt(crypto, bobSession, enc.encode('Got it!'));
expect(dec.decode(await ratchetDecrypt(crypto, aliceSession, reply))).toBe('Got it!');
});
test('one-time prekey consumed after use', async () => {
const bobStorage = new MemoryStorage();
const bobIdentity = await generateIdentityKeyPair(crypto);
await bobStorage.saveIdentityKeyPair(bobIdentity);
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
await bobStorage.saveSignedPreKey(bobSignedPreKey);
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3);
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
expect(await bobStorage.getOneTimePreKeyCount()).toBe(3);
// Alice uses OTPK 100
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
const aliceStorage = new MemoryStorage();
const aliceIdentity = await generateIdentityKeyPair(crypto);
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
const aliceSession = await initSenderSession(
crypto, x3dhResult.rootKey, x3dhResult.remoteIdentityKey, x3dhResult.remoteSignedPreKey,
);
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('test'));
await processPreKeyMessage(crypto, bobStorage, {
registrationId: 1,
preKeyId: 100,
signedPreKeyId: 1,
ephemeralKey: x3dhResult.ephemeralPublicKey,
identityDHKey: aliceIdentity.dhPublicKey,
message: firstMsg,
});
// OTPK 100 consumed, 101 and 102 remain
expect(await bobStorage.getOneTimePreKeyCount()).toBe(2);
expect(await bobStorage.getOneTimePreKey(100)).toBeNull();
expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull();
});
});