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:
183
packages/shade-core/tests/integration.test.ts
Normal file
183
packages/shade-core/tests/integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
179
packages/shade-core/tests/keys.test.ts
Normal file
179
packages/shade-core/tests/keys.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { kdfRootKey, kdfChainKey, deriveInitialRootKey } from '../src/keys.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
describe('KDF Chain Functions', () => {
|
||||
describe('kdfRootKey', () => {
|
||||
test('produces 32-byte root key and 32-byte chain key', async () => {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const dhOutput = crypto.randomBytes(32);
|
||||
|
||||
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
expect(newRootKey.length).toBe(32);
|
||||
expect(chainKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('new root key differs from input root key', async () => {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const dhOutput = crypto.randomBytes(32);
|
||||
|
||||
const { newRootKey } = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
expect(newRootKey).not.toEqual(rootKey);
|
||||
});
|
||||
|
||||
test('root key and chain key differ from each other', async () => {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const dhOutput = crypto.randomBytes(32);
|
||||
|
||||
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
expect(newRootKey).not.toEqual(chainKey);
|
||||
});
|
||||
|
||||
test('deterministic: same inputs produce same outputs', async () => {
|
||||
const rootKey = new Uint8Array(32).fill(0x11);
|
||||
const dhOutput = new Uint8Array(32).fill(0x22);
|
||||
|
||||
const a = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
const b = await kdfRootKey(crypto, rootKey, dhOutput);
|
||||
expect(a.newRootKey).toEqual(b.newRootKey);
|
||||
expect(a.chainKey).toEqual(b.chainKey);
|
||||
});
|
||||
|
||||
test('different DH output produces different keys', async () => {
|
||||
const rootKey = crypto.randomBytes(32);
|
||||
const dh1 = crypto.randomBytes(32);
|
||||
const dh2 = crypto.randomBytes(32);
|
||||
|
||||
const a = await kdfRootKey(crypto, rootKey, dh1);
|
||||
const b = await kdfRootKey(crypto, rootKey, dh2);
|
||||
expect(a.newRootKey).not.toEqual(b.newRootKey);
|
||||
expect(a.chainKey).not.toEqual(b.chainKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kdfChainKey', () => {
|
||||
test('produces 32-byte chain key and 32-byte message key', async () => {
|
||||
const chainKey = crypto.randomBytes(32);
|
||||
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey);
|
||||
expect(newChainKey.length).toBe(32);
|
||||
expect(messageKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('chain key and message key differ', async () => {
|
||||
const chainKey = crypto.randomBytes(32);
|
||||
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey);
|
||||
expect(newChainKey).not.toEqual(messageKey);
|
||||
});
|
||||
|
||||
test('chain ratchet is one-way: cannot derive previous chain key', async () => {
|
||||
const ck0 = crypto.randomBytes(32);
|
||||
const { newChainKey: ck1 } = await kdfChainKey(crypto, ck0);
|
||||
const { newChainKey: ck2 } = await kdfChainKey(crypto, ck1);
|
||||
|
||||
// All three are different
|
||||
expect(ck0).not.toEqual(ck1);
|
||||
expect(ck1).not.toEqual(ck2);
|
||||
expect(ck0).not.toEqual(ck2);
|
||||
});
|
||||
|
||||
test('deterministic: same input produces same output', async () => {
|
||||
const chainKey = new Uint8Array(32).fill(0x33);
|
||||
|
||||
const a = await kdfChainKey(crypto, chainKey);
|
||||
const b = await kdfChainKey(crypto, chainKey);
|
||||
expect(a.newChainKey).toEqual(b.newChainKey);
|
||||
expect(a.messageKey).toEqual(b.messageKey);
|
||||
});
|
||||
|
||||
test('sequential chain steps produce unique message keys', async () => {
|
||||
let ck = crypto.randomBytes(32);
|
||||
const messageKeys: Uint8Array[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const { newChainKey, messageKey } = await kdfChainKey(crypto, ck);
|
||||
messageKeys.push(messageKey);
|
||||
ck = newChainKey;
|
||||
}
|
||||
|
||||
// All message keys should be unique
|
||||
for (let i = 0; i < messageKeys.length; i++) {
|
||||
for (let j = i + 1; j < messageKeys.length; j++) {
|
||||
expect(messageKeys[i]).not.toEqual(messageKeys[j]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveInitialRootKey', () => {
|
||||
test('produces 32-byte root key from multiple DH outputs', async () => {
|
||||
const secrets = [
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
];
|
||||
|
||||
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||||
expect(rootKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('works with 3 secrets (no one-time prekey)', async () => {
|
||||
const secrets = [
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
];
|
||||
|
||||
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||||
expect(rootKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('works with 4 secrets (with one-time prekey)', async () => {
|
||||
const secrets = [
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
crypto.randomBytes(32),
|
||||
];
|
||||
|
||||
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||||
expect(rootKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('deterministic: same secrets produce same root key', async () => {
|
||||
const secrets = [
|
||||
new Uint8Array(32).fill(0xaa),
|
||||
new Uint8Array(32).fill(0xbb),
|
||||
new Uint8Array(32).fill(0xcc),
|
||||
];
|
||||
|
||||
const a = await deriveInitialRootKey(crypto, secrets);
|
||||
const b = await deriveInitialRootKey(crypto, secrets);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test('different secrets produce different root keys', async () => {
|
||||
const secretsA = [crypto.randomBytes(32), crypto.randomBytes(32), crypto.randomBytes(32)];
|
||||
const secretsB = [crypto.randomBytes(32), crypto.randomBytes(32), crypto.randomBytes(32)];
|
||||
|
||||
const a = await deriveInitialRootKey(crypto, secretsA);
|
||||
const b = await deriveInitialRootKey(crypto, secretsB);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
test('adding a 4th secret changes the root key', async () => {
|
||||
const base = [
|
||||
new Uint8Array(32).fill(0x11),
|
||||
new Uint8Array(32).fill(0x22),
|
||||
new Uint8Array(32).fill(0x33),
|
||||
];
|
||||
|
||||
const without = await deriveInitialRootKey(crypto, base);
|
||||
const withExtra = await deriveInitialRootKey(crypto, [...base, new Uint8Array(32).fill(0x44)]);
|
||||
expect(without).not.toEqual(withExtra);
|
||||
});
|
||||
});
|
||||
});
|
||||
262
packages/shade-core/tests/ratchet.test.ts
Normal file
262
packages/shade-core/tests/ratchet.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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 ──────────────────────────────────
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
321
packages/shade-core/tests/x3dh.test.ts
Normal file
321
packages/shade-core/tests/x3dh.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||||
import {
|
||||
generateIdentityKeyPair,
|
||||
generateSignedPreKey,
|
||||
generateOneTimePreKeys,
|
||||
createPreKeyBundle,
|
||||
processPreKeyBundle,
|
||||
processPreKeyMessage,
|
||||
InvalidSignatureError,
|
||||
PreKeyNotFoundError,
|
||||
} from '../src/index.js';
|
||||
import type { RatchetMessage } from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
/** Create a dummy RatchetMessage for testing (X3DH doesn't care about the content) */
|
||||
function dummyRatchetMessage(): RatchetMessage {
|
||||
return {
|
||||
dhPublicKey: crypto.randomBytes(32),
|
||||
previousCounter: 0,
|
||||
counter: 0,
|
||||
ciphertext: crypto.randomBytes(48),
|
||||
nonce: crypto.randomBytes(12),
|
||||
};
|
||||
}
|
||||
|
||||
describe('X3DH', () => {
|
||||
let aliceStorage: MemoryStorage;
|
||||
let bobStorage: MemoryStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
aliceStorage = new MemoryStorage();
|
||||
bobStorage = new MemoryStorage();
|
||||
});
|
||||
|
||||
// ─── Key Generation ────────────────────────────────────────
|
||||
|
||||
describe('key generation', () => {
|
||||
test('generates identity keypair with correct lengths', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
expect(id.signingPublicKey.length).toBe(32);
|
||||
expect(id.signingPrivateKey.length).toBe(32);
|
||||
expect(id.dhPublicKey.length).toBe(32);
|
||||
expect(id.dhPrivateKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('signing and DH keys are different', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
expect(id.signingPublicKey).not.toEqual(id.dhPublicKey);
|
||||
expect(id.signingPrivateKey).not.toEqual(id.dhPrivateKey);
|
||||
});
|
||||
|
||||
test('generates signed prekey with valid signature', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const spk = await generateSignedPreKey(crypto, id, 1);
|
||||
|
||||
expect(spk.keyId).toBe(1);
|
||||
expect(spk.keyPair.publicKey.length).toBe(32);
|
||||
expect(spk.signature.length).toBe(64);
|
||||
|
||||
// Verify the signature
|
||||
const valid = await crypto.verify(id.signingPublicKey, spk.keyPair.publicKey, spk.signature);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
test('generates batch of one-time prekeys', async () => {
|
||||
const otpks = await generateOneTimePreKeys(crypto, 100, 5);
|
||||
expect(otpks.length).toBe(5);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(otpks[i].keyId).toBe(100 + i);
|
||||
expect(otpks[i].keyPair.publicKey.length).toBe(32);
|
||||
}
|
||||
|
||||
// All keys are unique
|
||||
const pubKeys = otpks.map((k) => Array.from(k.keyPair.publicKey).join(','));
|
||||
expect(new Set(pubKeys).size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Full Handshake ────────────────────────────────────────
|
||||
|
||||
describe('full handshake', () => {
|
||||
test('Alice and Bob derive the same root key (with one-time prekey)', async () => {
|
||||
// Bob generates keys
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
const bobOneTimePreKeys = await generateOneTimePreKeys(crypto, 100, 3);
|
||||
|
||||
// Bob stores his keys
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
for (const otpk of bobOneTimePreKeys) {
|
||||
await bobStorage.saveOneTimePreKey(otpk);
|
||||
}
|
||||
|
||||
// Bob publishes a bundle (server would store this)
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOneTimePreKeys[0]);
|
||||
|
||||
// Alice generates her identity
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
// Alice processes the bundle
|
||||
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
|
||||
expect(aliceResult.rootKey.length).toBe(32);
|
||||
expect(aliceResult.signedPreKeyId).toBe(1);
|
||||
expect(aliceResult.preKeyId).toBe(100);
|
||||
expect(aliceResult.ephemeralPublicKey.length).toBe(32);
|
||||
|
||||
// Alice creates a PreKeyMessage
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
preKeyId: aliceResult.preKeyId,
|
||||
signedPreKeyId: aliceResult.signedPreKeyId,
|
||||
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
// Bob processes the PreKeyMessage
|
||||
const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||||
|
||||
// Both derive the same root key
|
||||
expect(bobResult.rootKey).toEqual(aliceResult.rootKey);
|
||||
expect(bobResult.remoteIdentityKey).toEqual(aliceIdentity.dhPublicKey);
|
||||
expect(bobResult.remoteEphemeralKey).toEqual(aliceResult.ephemeralPublicKey);
|
||||
});
|
||||
|
||||
test('Alice and Bob derive the same root key (without one-time prekey)', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
|
||||
// Bundle without one-time prekey
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
expect(aliceResult.preKeyId).toBeUndefined();
|
||||
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
signedPreKeyId: aliceResult.signedPreKeyId,
|
||||
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||||
expect(bobResult.rootKey).toEqual(aliceResult.rootKey);
|
||||
});
|
||||
|
||||
test('different handshakes produce different root keys', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||||
|
||||
// Alice 1
|
||||
const alice1Id = await generateIdentityKeyPair(crypto);
|
||||
const alice1Storage = new MemoryStorage();
|
||||
await alice1Storage.saveIdentityKeyPair(alice1Id);
|
||||
const result1 = await processPreKeyBundle(crypto, alice1Storage, bundle);
|
||||
|
||||
// Alice 2 (different identity)
|
||||
const alice2Id = await generateIdentityKeyPair(crypto);
|
||||
const alice2Storage = new MemoryStorage();
|
||||
await alice2Storage.saveIdentityKeyPair(alice2Id);
|
||||
const result2 = await processPreKeyBundle(crypto, alice2Storage, bundle);
|
||||
|
||||
expect(result1.rootKey).not.toEqual(result2.rootKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Signature Verification ────────────────────────────────
|
||||
|
||||
describe('signature verification', () => {
|
||||
test('rejects bundle with invalid signed prekey signature', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
|
||||
// Tamper with the signature
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||||
bundle.signedPreKey.signature[0] ^= 0xff;
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError);
|
||||
});
|
||||
|
||||
test('rejects bundle with wrong identity key signing', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const eveIdentity = await generateIdentityKeyPair(crypto);
|
||||
|
||||
// Eve signs the prekey, but claims to be Bob
|
||||
const eveSignedPreKey = await generateSignedPreKey(crypto, eveIdentity, 1);
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, eveSignedPreKey);
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── One-Time Prekey Consumption ───────────────────────────
|
||||
|
||||
describe('one-time prekey consumption', () => {
|
||||
test('one-time prekey is deleted after use', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
|
||||
|
||||
expect(await bobStorage.getOneTimePreKeyCount()).toBe(3);
|
||||
|
||||
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
preKeyId: aliceResult.preKeyId,
|
||||
signedPreKeyId: aliceResult.signedPreKeyId,
|
||||
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||||
|
||||
// One-time prekey 100 should be consumed
|
||||
expect(await bobStorage.getOneTimePreKeyCount()).toBe(2);
|
||||
expect(await bobStorage.getOneTimePreKey(100)).toBeNull();
|
||||
// Others remain
|
||||
expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull();
|
||||
expect(await bobStorage.getOneTimePreKey(102)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('fails when referenced one-time prekey does not exist', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||||
// No one-time prekeys stored
|
||||
|
||||
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||||
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||||
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
preKeyId: 999, // doesn't exist
|
||||
signedPreKeyId: 1,
|
||||
ephemeralKey: crypto.randomBytes(32),
|
||||
identityDHKey: aliceIdentity.dhPublicKey,
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError);
|
||||
});
|
||||
|
||||
test('fails when referenced signed prekey does not exist', async () => {
|
||||
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||||
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||||
// No signed prekey stored
|
||||
|
||||
const preKeyMessage = {
|
||||
registrationId: 1,
|
||||
signedPreKeyId: 999,
|
||||
ephemeralKey: crypto.randomBytes(32),
|
||||
identityDHKey: crypto.randomBytes(32),
|
||||
message: dummyRatchetMessage(),
|
||||
};
|
||||
|
||||
expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PreKey Bundle Assembly ────────────────────────────────
|
||||
|
||||
describe('createPreKeyBundle', () => {
|
||||
test('assembles bundle with one-time prekey', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const spk = await generateSignedPreKey(crypto, id, 5);
|
||||
const otpk = (await generateOneTimePreKeys(crypto, 200, 1))[0];
|
||||
|
||||
const bundle = createPreKeyBundle(42, id, spk, otpk);
|
||||
|
||||
expect(bundle.registrationId).toBe(42);
|
||||
expect(bundle.identitySigningKey).toEqual(id.signingPublicKey);
|
||||
expect(bundle.identityDHKey).toEqual(id.dhPublicKey);
|
||||
expect(bundle.signedPreKey.keyId).toBe(5);
|
||||
expect(bundle.signedPreKey.publicKey).toEqual(spk.keyPair.publicKey);
|
||||
expect(bundle.signedPreKey.signature).toEqual(spk.signature);
|
||||
expect(bundle.oneTimePreKey?.keyId).toBe(200);
|
||||
expect(bundle.oneTimePreKey?.publicKey).toEqual(otpk.keyPair.publicKey);
|
||||
});
|
||||
|
||||
test('assembles bundle without one-time prekey', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const spk = await generateSignedPreKey(crypto, id, 1);
|
||||
|
||||
const bundle = createPreKeyBundle(42, id, spk);
|
||||
|
||||
expect(bundle.oneTimePreKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user