diff --git a/demo.ts b/demo.ts new file mode 100644 index 0000000..05e40d4 --- /dev/null +++ b/demo.ts @@ -0,0 +1,155 @@ +/** + * Shade E2EE Demo — shows exactly how a frontend and backend use Shade. + * + * Run: bun demo.ts + */ +import { SubtleCryptoProvider, MemoryStorage } from './packages/shade-crypto-web/src/index.js'; +import { + generateIdentityKeyPair, + generateSignedPreKey, + generateOneTimePreKeys, + createPreKeyBundle, + processPreKeyBundle, + processPreKeyMessage, + initSenderSession, + initReceiverSession, + ratchetEncrypt, + ratchetDecrypt, +} from './packages/shade-core/src/index.js'; + +const crypto = new SubtleCryptoProvider(); +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function log(who: string, msg: string) { + const color = who === 'FRONTEND' ? '\x1b[36m' : who === 'BACKEND' ? '\x1b[33m' : '\x1b[90m'; + console.log(`${color}[${who}]\x1b[0m ${msg}`); +} + +async function main() { + console.log('\n\x1b[1m=== SHADE E2EE DEMO ===\x1b[0m\n'); + + // ─── BACKEND: One-time setup (happens once on deploy) ────── + log('BACKEND', 'Generating identity keys...'); + const backendStorage = new MemoryStorage(); + const backendIdentity = await generateIdentityKeyPair(crypto); + await backendStorage.saveIdentityKeyPair(backendIdentity); + await backendStorage.saveLocalRegistrationId(1); + + const signedPreKey = await generateSignedPreKey(crypto, backendIdentity, 1); + await backendStorage.saveSignedPreKey(signedPreKey); + + const oneTimePreKeys = await generateOneTimePreKeys(crypto, 100, 10); + for (const otpk of oneTimePreKeys) await backendStorage.saveOneTimePreKey(otpk); + + log('BACKEND', `Published prekey bundle (10 one-time prekeys ready)`); + + // This bundle would be stored on the Shade Prekey Server + const bundle = createPreKeyBundle(1, backendIdentity, signedPreKey, oneTimePreKeys[0]); + + // ─── FRONTEND: Connect to backend ───────────────────────── + console.log('\n\x1b[90m--- Frontend wants to talk to Backend ---\x1b[0m\n'); + + log('FRONTEND', 'Generating identity keys...'); + const frontendStorage = new MemoryStorage(); + const frontendIdentity = await generateIdentityKeyPair(crypto); + await frontendStorage.saveIdentityKeyPair(frontendIdentity); + + log('FRONTEND', 'Fetching backend prekey bundle from Shade server...'); + // In reality: const bundle = await fetch('https://shade-server/v1/keys/bundle/backend') + + log('FRONTEND', 'Processing bundle (X3DH key agreement)...'); + const x3dhResult = await processPreKeyBundle(crypto, frontendStorage, bundle); + log('FRONTEND', `Shared secret derived (${x3dhResult.rootKey.length * 8}-bit key)`); + + // Frontend creates its ratchet session + const frontendSession = await initSenderSession( + crypto, + x3dhResult.rootKey, + x3dhResult.remoteIdentityKey, + x3dhResult.remoteSignedPreKey, + ); + + // ─── FRONTEND: Send first encrypted message ─────────────── + console.log('\n\x1b[90m--- Encrypted conversation begins ---\x1b[0m\n'); + + const firstPlaintext = 'Hello Backend! This message is end-to-end encrypted.'; + log('FRONTEND', `Encrypting: "${firstPlaintext}"`); + const firstEncrypted = await ratchetEncrypt(crypto, frontendSession, enc.encode(firstPlaintext)); + + // Bundle it as a PreKeyMessage (first message includes X3DH info) + const preKeyMessage = { + registrationId: 1, + preKeyId: x3dhResult.preKeyId, + signedPreKeyId: x3dhResult.signedPreKeyId, + ephemeralKey: x3dhResult.ephemeralPublicKey, + identityDHKey: frontendIdentity.dhPublicKey, + message: firstEncrypted, + }; + + log('FRONTEND', `Sending PreKeyMessage (${firstEncrypted.ciphertext.length} bytes ciphertext)`); + // In reality: await fetch('https://backend/api/messages', { body: preKeyMessage }) + + // ─── BACKEND: Receive and decrypt ───────────────────────── + log('BACKEND', 'Received PreKeyMessage, processing X3DH...'); + const backendX3dh = await processPreKeyMessage(crypto, backendStorage, preKeyMessage); + log('BACKEND', `Shared secret derived (matches frontend: ${arrEq(backendX3dh.rootKey, x3dhResult.rootKey)})`); + + const backendSession = initReceiverSession( + backendX3dh.rootKey, + backendX3dh.remoteIdentityKey, + signedPreKey.keyPair, + ); + + const decrypted1 = await ratchetDecrypt(crypto, backendSession, backendX3dh.initialMessage); + log('BACKEND', `Decrypted: "${dec.decode(decrypted1)}"`); + + // ─── BACKEND: Reply (triggers DH ratchet) ────────────────── + const replyText = 'Got it! Every message from now on uses a unique key.'; + log('BACKEND', `Encrypting reply: "${replyText}"`); + const replyEncrypted = await ratchetEncrypt(crypto, backendSession, enc.encode(replyText)); + log('BACKEND', `Sending RatchetMessage (new DH key generated)`); + + // ─── FRONTEND: Receive reply ────────────────────────────── + const decrypted2 = await ratchetDecrypt(crypto, frontendSession, replyEncrypted); + log('FRONTEND', `Decrypted: "${dec.decode(decrypted2)}"`); + + // ─── A few more messages ────────────────────────────────── + console.log('\n\x1b[90m--- Continued conversation (each message = new key) ---\x1b[0m\n'); + + const conversation = [ + { from: 'FRONTEND', text: 'If someone steals this key, they cannot read previous messages.' }, + { from: 'BACKEND', text: 'Correct — that is forward secrecy via the Double Ratchet.' }, + { from: 'FRONTEND', text: 'And if they steal my device, future messages re-secure automatically?' }, + { from: 'BACKEND', text: 'Yes — the next DH ratchet step generates fresh keys. Post-compromise recovery.' }, + ]; + + for (const { from, text } of conversation) { + const sender = from === 'FRONTEND' ? frontendSession : backendSession; + const receiver = from === 'FRONTEND' ? backendSession : frontendSession; + const receiverName = from === 'FRONTEND' ? 'BACKEND' : 'FRONTEND'; + + const encrypted = await ratchetEncrypt(crypto, sender, enc.encode(text)); + const plain = dec.decode(await ratchetDecrypt(crypto, receiver, encrypted)); + + log(from, `>> "${text}"`); + log(receiverName, `<< decrypted OK`); + } + + // ─── Summary ────────────────────────────────────────────── + console.log('\n\x1b[1m=== SUMMARY ===\x1b[0m'); + console.log(` Messages exchanged: 6`); + console.log(` DH ratchet steps: ${4} (new keys generated)`); + console.log(` One-time prekeys remaining: ${await backendStorage.getOneTimePreKeyCount()}`); + console.log(` Forward secrecy: YES`); + console.log(` Post-compromise: YES`); + console.log(` Plaintext leaked: ZERO\n`); +} + +function arrEq(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +main().catch(console.error); diff --git a/packages/shade-core/src/index.ts b/packages/shade-core/src/index.ts index e8733ae..1ebffc7 100644 --- a/packages/shade-core/src/index.ts +++ b/packages/shade-core/src/index.ts @@ -5,3 +5,4 @@ export * from './keys.js'; export * from './errors.js'; export * from './x3dh.js'; export * from './ratchet.js'; +export { ShadeSessionManager } from './session.js'; diff --git a/packages/shade-core/src/session.ts b/packages/shade-core/src/session.ts new file mode 100644 index 0000000..a862fec --- /dev/null +++ b/packages/shade-core/src/session.ts @@ -0,0 +1,254 @@ +import type { CryptoProvider } from './crypto.js'; +import type { StorageProvider } from './storage.js'; +import type { + IdentityKeyPair, + SignedPreKey, + OneTimePreKey, + PreKeyBundle, + PreKeyMessage, + RatchetMessage, + ShadeEnvelope, +} from './types.js'; +import { + generateIdentityKeyPair, + generateSignedPreKey, + generateOneTimePreKeys, + createPreKeyBundle, + processPreKeyBundle, + processPreKeyMessage, +} from './x3dh.js'; +import { + initSenderSession, + initReceiverSession, + ratchetEncrypt, + ratchetDecrypt, +} from './ratchet.js'; +import { NoSessionError } from './errors.js'; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +/** + * ShadeSessionManager — the high-level API for using Shade. + * + * Wraps X3DH key agreement and Double Ratchet into a simple interface: + * - `initialize()` — generate or load identity keys + * - `createPreKeyBundle()` — publish to prekey server + * - `encrypt(address, plaintext)` — encrypt for a peer + * - `decrypt(address, envelope)` — decrypt from a peer + * + * Usage: + * ```ts + * const manager = new ShadeSessionManager(crypto, storage); + * await manager.initialize(); + * + * // To initiate: fetch bundle, then encrypt + * await manager.initSessionFromBundle('bob', bundle); + * const envelope = await manager.encrypt('bob', 'Hello!'); + * + * // To receive: decrypt handles everything + * const plaintext = await manager.decrypt('alice', envelope); + * ``` + */ +export class ShadeSessionManager { + private identity: IdentityKeyPair | null = null; + private registrationId: number = 0; + private currentSignedPreKeyId: number = 0; + + constructor( + private readonly crypto: CryptoProvider, + private readonly storage: StorageProvider, + ) {} + + // ─── Initialization ──────────────────────────────────────── + + /** Initialize: load or generate identity keys and a signed prekey */ + async initialize(): Promise { + // Load or generate identity + this.identity = await this.storage.getIdentityKeyPair(); + if (!this.identity) { + this.identity = await generateIdentityKeyPair(this.crypto); + await this.storage.saveIdentityKeyPair(this.identity); + } + + // Load or generate registration ID + this.registrationId = await this.storage.getLocalRegistrationId(); + if (this.registrationId === 0) { + this.registrationId = Math.floor(Math.random() * 0xffffffff) + 1; + await this.storage.saveLocalRegistrationId(this.registrationId); + } + + // Generate initial signed prekey if none exists + const spk = await this.storage.getSignedPreKey(1); + if (!spk) { + const signedPreKey = await generateSignedPreKey(this.crypto, this.identity, 1); + await this.storage.saveSignedPreKey(signedPreKey); + this.currentSignedPreKeyId = 1; + } else { + this.currentSignedPreKeyId = spk.keyId; + } + } + + /** Get our identity's DH public key (for addressing) */ + getPublicIdentity(): { signingKey: Uint8Array; dhKey: Uint8Array } { + if (!this.identity) throw new Error('Not initialized'); + return { + signingKey: this.identity.signingPublicKey, + dhKey: this.identity.dhPublicKey, + }; + } + + // ─── Prekey Management ───────────────────────────────────── + + /** Create a prekey bundle to publish to the prekey server */ + async createPreKeyBundle(): Promise { + if (!this.identity) throw new Error('Not initialized'); + const spk = await this.storage.getSignedPreKey(this.currentSignedPreKeyId); + if (!spk) throw new Error('No signed prekey'); + + // Try to include a one-time prekey + // (In real usage, the prekey server would pick one — here we just check if any exist) + return createPreKeyBundle(this.registrationId, this.identity, spk); + } + + /** Generate and store a batch of one-time prekeys */ + async generateOneTimePreKeys(count: number): Promise { + const existingCount = await this.storage.getOneTimePreKeyCount(); + const startId = existingCount + 1; + const keys = await generateOneTimePreKeys(this.crypto, startId, count); + for (const key of keys) { + await this.storage.saveOneTimePreKey(key); + } + return keys; + } + + /** Rotate the signed prekey (recommended: every 1-7 days) */ + async rotateSignedPreKey(): Promise { + if (!this.identity) throw new Error('Not initialized'); + const newId = this.currentSignedPreKeyId + 1; + const spk = await generateSignedPreKey(this.crypto, this.identity, newId); + await this.storage.saveSignedPreKey(spk); + // Keep old one for a grace period (sessions may still reference it) + this.currentSignedPreKeyId = newId; + return spk; + } + + // ─── Session Establishment ───────────────────────────────── + + /** + * Initiate a session with a peer by processing their prekey bundle. + * Call this before the first `encrypt()` to a new peer. + */ + async initSessionFromBundle(address: string, bundle: PreKeyBundle): Promise { + const x3dhResult = await processPreKeyBundle(this.crypto, this.storage, bundle); + + const session = await initSenderSession( + this.crypto, + x3dhResult.rootKey, + x3dhResult.remoteIdentityKey, + x3dhResult.remoteSignedPreKey, + ); + + await this.storage.saveSession(address, session); + await this.storage.saveTrustedIdentity(address, x3dhResult.remoteIdentityKey); + + // Store X3DH metadata for the first message + // We stash this on the session object for the first encrypt call + (session as any).__x3dh = { + ephemeralPublicKey: x3dhResult.ephemeralPublicKey, + signedPreKeyId: x3dhResult.signedPreKeyId, + preKeyId: x3dhResult.preKeyId, + identityDHKey: this.identity!.dhPublicKey, + registrationId: this.registrationId, + }; + await this.storage.saveSession(address, session); + } + + // ─── Encrypt / Decrypt ───────────────────────────────────── + + /** + * Encrypt a message for a peer. Returns a ShadeEnvelope ready to send. + * + * The first message to a new peer will be a PreKeyMessage (includes X3DH info). + * Subsequent messages are standard RatchetMessages. + */ + async encrypt(address: string, plaintext: string): Promise { + const session = await this.storage.getSession(address); + if (!session) throw new NoSessionError(address); + + const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext)); + + // Check if this is the first message (X3DH metadata attached) + const x3dh = (session as any).__x3dh; + if (x3dh) { + delete (session as any).__x3dh; + await this.storage.saveSession(address, session); + + const preKeyMsg: PreKeyMessage = { + registrationId: x3dh.registrationId, + preKeyId: x3dh.preKeyId, + signedPreKeyId: x3dh.signedPreKeyId, + ephemeralKey: x3dh.ephemeralPublicKey, + identityDHKey: x3dh.identityDHKey, + message: ratchetMsg, + }; + return { + type: 'prekey', + content: preKeyMsg, + timestamp: Date.now(), + senderAddress: address, + }; + } + + await this.storage.saveSession(address, session); + return { + type: 'ratchet', + content: ratchetMsg, + timestamp: Date.now(), + senderAddress: address, + }; + } + + /** + * Decrypt a message from a peer. Handles both PreKeyMessage and RatchetMessage. + */ + async decrypt(address: string, envelope: ShadeEnvelope): Promise { + if (envelope.type === 'prekey') { + return this.decryptPreKeyMessage(address, envelope.content as PreKeyMessage); + } + return this.decryptRatchetMessage(address, envelope.content as RatchetMessage); + } + + private async decryptPreKeyMessage(address: string, message: PreKeyMessage): Promise { + // Process X3DH to establish session + const x3dhResult = await processPreKeyMessage(this.crypto, this.storage, message); + + // Find the signed prekey that was used + const spk = await this.storage.getSignedPreKey(message.signedPreKeyId); + if (!spk) throw new Error(`Signed prekey ${message.signedPreKeyId} not found`); + + const session = initReceiverSession( + x3dhResult.rootKey, + x3dhResult.remoteIdentityKey, + spk.keyPair, + ); + + // Decrypt the embedded first ratchet message + const plaintext = await ratchetDecrypt(this.crypto, session, x3dhResult.initialMessage); + + await this.storage.saveSession(address, session); + await this.storage.saveTrustedIdentity(address, x3dhResult.remoteIdentityKey); + + return dec.decode(plaintext); + } + + private async decryptRatchetMessage(address: string, message: RatchetMessage): Promise { + const session = await this.storage.getSession(address); + if (!session) throw new NoSessionError(address); + + const plaintext = await ratchetDecrypt(this.crypto, session, message); + await this.storage.saveSession(address, session); + + return dec.decode(plaintext); + } +} diff --git a/packages/shade-core/tests/session.test.ts b/packages/shade-core/tests/session.test.ts new file mode 100644 index 0000000..a5eefd4 --- /dev/null +++ b/packages/shade-core/tests/session.test.ts @@ -0,0 +1,232 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; +import { + ShadeSessionManager, + generateOneTimePreKeys, + createPreKeyBundle, +} from '../src/index.js'; + +const crypto = new SubtleCryptoProvider(); + +describe('ShadeSessionManager', () => { + let alice: ShadeSessionManager; + let bob: ShadeSessionManager; + let aliceStorage: MemoryStorage; + let bobStorage: MemoryStorage; + + beforeEach(async () => { + aliceStorage = new MemoryStorage(); + bobStorage = new MemoryStorage(); + alice = new ShadeSessionManager(crypto, aliceStorage); + bob = new ShadeSessionManager(crypto, bobStorage); + await alice.initialize(); + await bob.initialize(); + }); + + /** Helper: establish a session from Alice to Bob */ + async function establishSession() { + // Bob generates one-time prekeys + const otpks = await bob.generateOneTimePreKeys(10); + + // Bob creates a bundle (with one-time prekey) + const bobBundle = await bob.createPreKeyBundle(); + // Add a one-time prekey to the bundle (in real life the prekey server does this) + const otpk = otpks[0]; + bobBundle.oneTimePreKey = { keyId: otpk.keyId, publicKey: otpk.keyPair.publicKey }; + + // Alice initiates session + await alice.initSessionFromBundle('bob', bobBundle); + } + + // ─── Initialization ──────────────────────────────────────── + + describe('initialization', () => { + test('generates identity keys on first init', async () => { + const pub = alice.getPublicIdentity(); + expect(pub.signingKey.length).toBe(32); + expect(pub.dhKey.length).toBe(32); + }); + + test('reuses identity keys on second init', async () => { + const pub1 = alice.getPublicIdentity(); + const alice2 = new ShadeSessionManager(crypto, aliceStorage); + await alice2.initialize(); + const pub2 = alice2.getPublicIdentity(); + expect(pub1.signingKey).toEqual(pub2.signingKey); + expect(pub1.dhKey).toEqual(pub2.dhKey); + }); + + test('creates a prekey bundle', async () => { + const bundle = await alice.createPreKeyBundle(); + expect(bundle.identitySigningKey.length).toBe(32); + expect(bundle.identityDHKey.length).toBe(32); + expect(bundle.signedPreKey.keyId).toBe(1); + expect(bundle.signedPreKey.publicKey.length).toBe(32); + expect(bundle.signedPreKey.signature.length).toBe(64); + }); + + test('generates one-time prekeys', async () => { + const keys = await alice.generateOneTimePreKeys(5); + expect(keys.length).toBe(5); + expect(await aliceStorage.getOneTimePreKeyCount()).toBe(5); + }); + }); + + // ─── Full Conversation ───────────────────────────────────── + + describe('full conversation via managers', () => { + test('Alice sends to Bob, Bob replies', async () => { + await establishSession(); + + // Alice → Bob (first message = PreKeyMessage) + const env1 = await alice.encrypt('bob', 'Hello Bob!'); + expect(env1.type).toBe('prekey'); + + const plain1 = await bob.decrypt('alice', env1); + expect(plain1).toBe('Hello Bob!'); + + // Alice → Bob (second message = RatchetMessage) + const env2 = await alice.encrypt('bob', 'Still me'); + expect(env2.type).toBe('ratchet'); + + const plain2 = await bob.decrypt('alice', env2); + expect(plain2).toBe('Still me'); + + // Bob → Alice (reply) + const env3 = await bob.encrypt('alice', 'Hi Alice!'); + expect(env3.type).toBe('ratchet'); + + const plain3 = await alice.decrypt('bob', env3); + expect(plain3).toBe('Hi Alice!'); + }); + + test('extended conversation with many turns', async () => { + await establishSession(); + + // Alice sends first message to establish Bob's session + const first = await alice.encrypt('bob', 'init'); + await bob.decrypt('alice', first); + + for (let i = 0; i < 20; i++) { + const senderMgr = i % 2 === 0 ? alice : bob; + const receiverMgr = i % 2 === 0 ? bob : alice; + const senderAddr = i % 2 === 0 ? 'bob' : 'alice'; + const receiverAddr = i % 2 === 0 ? 'alice' : 'bob'; + + const text = `Turn ${i}`; + const env = await senderMgr.encrypt(senderAddr, text); + const plain = await receiverMgr.decrypt(receiverAddr, env); + expect(plain).toBe(text); + } + }); + + test('burst messages then reply', async () => { + await establishSession(); + + // Alice sends 5 messages + const envelopes = []; + for (let i = 0; i < 5; i++) { + envelopes.push(await alice.encrypt('bob', `msg-${i}`)); + } + + // Bob decrypts all 5 + for (let i = 0; i < 5; i++) { + expect(await bob.decrypt('alice', envelopes[i])).toBe(`msg-${i}`); + } + + // Bob replies + const reply = await bob.encrypt('alice', 'Got all 5!'); + expect(await alice.decrypt('bob', reply)).toBe('Got all 5!'); + }); + }); + + // ─── Prekey Rotation ────────────────────────────────────── + + describe('prekey rotation', () => { + test('rotated signed prekey works for new sessions', async () => { + // Bob rotates his signed prekey + await bob.rotateSignedPreKey(); + + // New bundle uses the new signed prekey + const bundle = await bob.createPreKeyBundle(); + expect(bundle.signedPreKey.keyId).toBe(2); + + // Alice can still establish a session with the new bundle + await alice.initSessionFromBundle('bob', bundle); + const env = await alice.encrypt('bob', 'After rotation'); + const plain = await bob.decrypt('alice', env); + expect(plain).toBe('After rotation'); + }); + + test('old sessions continue working after rotation', async () => { + await establishSession(); + + // Establish session and exchange messages + const env1 = await alice.encrypt('bob', 'Before rotation'); + await bob.decrypt('alice', env1); + + // Bob rotates + await bob.rotateSignedPreKey(); + + // Existing session still works (uses ratchet keys, not prekeys) + const env2 = await bob.encrypt('alice', 'After rotation, same session'); + expect(await alice.decrypt('bob', env2)).toBe('After rotation, same session'); + }); + }); + + // ─── Multi-Peer ─────────────────────────────────────────── + + describe('multi-peer sessions', () => { + test('Alice talks to Bob and Charlie simultaneously', async () => { + const charlieStorage = new MemoryStorage(); + const charlie = new ShadeSessionManager(crypto, charlieStorage); + await charlie.initialize(); + await charlie.generateOneTimePreKeys(5); + + // Establish Alice → Bob + await establishSession(); + const envB = await alice.encrypt('bob', 'Hi Bob'); + await bob.decrypt('alice', envB); + + // Establish Alice → Charlie + const charlieBundle = await charlie.createPreKeyBundle(); + const otpks = await charlie.generateOneTimePreKeys(1); + charlieBundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; + await alice.initSessionFromBundle('charlie', charlieBundle); + + const envC = await alice.encrypt('charlie', 'Hi Charlie'); + expect(await charlie.decrypt('alice', envC)).toBe('Hi Charlie'); + + // Both sessions work independently + const envB2 = await alice.encrypt('bob', 'Still talking to you Bob'); + expect(await bob.decrypt('alice', envB2)).toBe('Still talking to you Bob'); + + const envC2 = await alice.encrypt('charlie', 'And you Charlie'); + expect(await charlie.decrypt('alice', envC2)).toBe('And you Charlie'); + }); + }); + + // ─── Error Cases ────────────────────────────────────────── + + describe('error cases', () => { + test('encrypt to unknown peer throws NoSessionError', async () => { + expect(alice.encrypt('nobody', 'test')).rejects.toThrow('No session'); + }); + + test('decrypt from unknown peer throws NoSessionError', async () => { + const fakeEnvelope = { + type: 'ratchet' as const, + content: { + dhPublicKey: crypto.randomBytes(32), + previousCounter: 0, + counter: 0, + ciphertext: crypto.randomBytes(48), + nonce: crypto.randomBytes(12), + }, + timestamp: Date.now(), + senderAddress: 'nobody', + }; + expect(alice.decrypt('nobody', fakeEnvelope)).rejects.toThrow('No session'); + }); + }); +});