177 lines
7.5 KiB
TypeScript
177 lines
7.5 KiB
TypeScript
|
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
||
|
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||
|
|
import {
|
||
|
|
ShadeSessionManager,
|
||
|
|
StreamRatchet,
|
||
|
|
StreamClosedError,
|
||
|
|
DecryptionError,
|
||
|
|
} from '../src/index.js';
|
||
|
|
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
const enc = new TextEncoder();
|
||
|
|
const dec = new TextDecoder();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Establish a *bidirectional* parent session: Alice→Bob X3DH, then one
|
||
|
|
* Alice→Bob message Bob decrypts so Bob also has a session for 'alice'.
|
||
|
|
* Both sides then hold the peer's pinned identity DH key — the input the
|
||
|
|
* stream handshake derives from.
|
||
|
|
*/
|
||
|
|
async function bidirectionalPair() {
|
||
|
|
const aliceStorage = new MemoryStorage();
|
||
|
|
const bobStorage = new MemoryStorage();
|
||
|
|
const alice = new ShadeSessionManager(crypto, aliceStorage);
|
||
|
|
const bob = new ShadeSessionManager(crypto, bobStorage);
|
||
|
|
await alice.initialize();
|
||
|
|
await bob.initialize();
|
||
|
|
|
||
|
|
const otpks = await bob.generateOneTimePreKeys(4);
|
||
|
|
const bundle = await bob.createPreKeyBundle();
|
||
|
|
bundle.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
|
||
|
|
await alice.initSessionFromBundle('bob', bundle);
|
||
|
|
|
||
|
|
const hello = await alice.encrypt('bob', 'parent-hello');
|
||
|
|
expect(await bob.decrypt('alice', hello)).toBe('parent-hello');
|
||
|
|
|
||
|
|
return { alice, bob, aliceStorage, bobStorage };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Run the full STREAM_OPEN / STREAM_OPEN_ACK handshake between managers. */
|
||
|
|
async function openStreamPair(alice: ShadeSessionManager, bob: ShadeSessionManager) {
|
||
|
|
const begun = await alice.beginStream('bob'); // initiator
|
||
|
|
const accepted = await bob.acceptStream('alice', begun.streamId, begun.ephemeralPublicKey);
|
||
|
|
const aliceStream = await begun.complete(accepted.ephemeralPublicKey);
|
||
|
|
return { aliceStream, bobStream: accepted.stream, streamId: begun.streamId };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('streaming sub-ratchet (V4.11)', () => {
|
||
|
|
let alice: ShadeSessionManager;
|
||
|
|
let bob: ShadeSessionManager;
|
||
|
|
let aliceStorage: MemoryStorage;
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
({ alice, bob, aliceStorage } = await bidirectionalPair());
|
||
|
|
});
|
||
|
|
|
||
|
|
test('both sides derive the same stream root (round-trips frames)', async () => {
|
||
|
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||
|
|
|
||
|
|
// Initiator → responder (first frame triggers responder DH step).
|
||
|
|
const f1 = await aliceStream.seal(enc.encode('log line 1'));
|
||
|
|
expect(dec.decode(await bobStream.open(f1))).toBe('log line 1');
|
||
|
|
|
||
|
|
// Responder → initiator (now responder may seal).
|
||
|
|
const r1 = await bobStream.seal(enc.encode('command-response 1'));
|
||
|
|
expect(dec.decode(await aliceStream.open(r1))).toBe('command-response 1');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('two streams to the same peer get independent roots', async () => {
|
||
|
|
const s1 = await openStreamPair(alice, bob);
|
||
|
|
const s2 = await openStreamPair(alice, bob);
|
||
|
|
expect(s1.streamId).not.toEqual(s2.streamId);
|
||
|
|
|
||
|
|
const a = await s1.aliceStream.seal(enc.encode('on stream 1'));
|
||
|
|
// A frame from stream 1 must not decrypt on stream 2's ratchet.
|
||
|
|
await expect(s2.bobStream.open(a)).rejects.toBeInstanceOf(DecryptionError);
|
||
|
|
// …but does on its own.
|
||
|
|
expect(dec.decode(await s1.bobStream.open(a))).toBe('on stream 1');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('R2/R3: long one-directional burst stays correct and memory-bounded', async () => {
|
||
|
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||
|
|
const N = 5000;
|
||
|
|
|
||
|
|
// Capture a live receive-chain key buffer to prove forward secrecy:
|
||
|
|
// ratchetDecrypt zeroizes the previous chain key in place.
|
||
|
|
await bobStream.open(await aliceStream.seal(enc.encode('frame-0')));
|
||
|
|
const bobSession = (bobStream as unknown as { session: { receiveChain: { chainKey: Uint8Array }; skippedKeys: Map<string, Uint8Array> } }).session;
|
||
|
|
const staleChainKey = bobSession.receiveChain.chainKey;
|
||
|
|
const staleCopy = staleChainKey.slice();
|
||
|
|
expect(staleCopy.some((b) => b !== 0)).toBe(true);
|
||
|
|
|
||
|
|
for (let i = 1; i < N; i++) {
|
||
|
|
const wire = await aliceStream.seal(enc.encode(`frame-${i}`));
|
||
|
|
expect(dec.decode(await bobStream.open(wire))).toBe(`frame-${i}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// In-order delivery ⇒ zero skipped keys retained across 5k frames.
|
||
|
|
expect(bobSession.skippedKeys.size).toBe(0);
|
||
|
|
// The chain key in use at frame 0 was overwritten (forward secrecy).
|
||
|
|
expect(staleChainKey.every((b) => b === 0)).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('R5: opening/using/closing a stream never touches the parent session', async () => {
|
||
|
|
const before = await aliceStorage.getSession('bob');
|
||
|
|
const snapshot = JSON.stringify({
|
||
|
|
root: Array.from(before!.rootKey),
|
||
|
|
sendCtr: before!.sendChain.counter,
|
||
|
|
prevCtr: before!.previousSendCounter,
|
||
|
|
});
|
||
|
|
|
||
|
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||
|
|
for (let i = 0; i < 200; i++) {
|
||
|
|
await bobStream.open(await aliceStream.seal(enc.encode(`x${i}`)));
|
||
|
|
}
|
||
|
|
await aliceStream.close();
|
||
|
|
await bobStream.close();
|
||
|
|
|
||
|
|
const after = await aliceStorage.getSession('bob');
|
||
|
|
expect(
|
||
|
|
JSON.stringify({
|
||
|
|
root: Array.from(after!.rootKey),
|
||
|
|
sendCtr: after!.sendChain.counter,
|
||
|
|
prevCtr: after!.previousSendCounter,
|
||
|
|
}),
|
||
|
|
).toBe(snapshot);
|
||
|
|
|
||
|
|
// Parent HTTP path still works after the stream lifecycle.
|
||
|
|
const env = await alice.encrypt('bob', 'after-stream');
|
||
|
|
expect(await bob.decrypt('alice', env)).toBe('after-stream');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('R1: replayed / rewound frame is rejected', async () => {
|
||
|
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||
|
|
const f1 = await aliceStream.seal(enc.encode('once'));
|
||
|
|
expect(dec.decode(await bobStream.open(f1))).toBe('once');
|
||
|
|
// Re-delivering the exact same sealed frame must fail.
|
||
|
|
await expect(bobStream.open(f1)).rejects.toBeInstanceOf(DecryptionError);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('close() zeroizes and blocks further use; idempotent', async () => {
|
||
|
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||
|
|
await aliceStream.close();
|
||
|
|
await aliceStream.close(); // idempotent
|
||
|
|
expect(aliceStream.closed).toBe(true);
|
||
|
|
await expect(aliceStream.seal(enc.encode('nope'))).rejects.toBeInstanceOf(
|
||
|
|
StreamClosedError,
|
||
|
|
);
|
||
|
|
// The peer end is unaffected by our local close.
|
||
|
|
expect(bobStream.closed).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('handshake is mutually authenticated against pinned identities', async () => {
|
||
|
|
// A third party (mallory) with its own identity cannot stand in for
|
||
|
|
// bob: alice derives against bob's pinned identity key, so a
|
||
|
|
// handshake completed with mallory's ephemeral yields a different
|
||
|
|
// root and frames fail to open.
|
||
|
|
const mStorage = new MemoryStorage();
|
||
|
|
const mallory = new ShadeSessionManager(crypto, mStorage);
|
||
|
|
await mallory.initialize();
|
||
|
|
// Give mallory a parent session label so acceptStream has identity
|
||
|
|
// material, but pinned to the WRONG (alice) identity vs what alice
|
||
|
|
// pinned for 'bob'.
|
||
|
|
const otpks = await mallory.generateOneTimePreKeys(2);
|
||
|
|
const mb = await mallory.createPreKeyBundle();
|
||
|
|
mb.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
|
||
|
|
await alice.initSessionFromBundle('mallory', mb);
|
||
|
|
const helo = await alice.encrypt('mallory', 'hi');
|
||
|
|
await mallory.decrypt('alice', helo);
|
||
|
|
|
||
|
|
const begun = await alice.beginStream('bob');
|
||
|
|
const mAccept = await mallory.acceptStream('alice', begun.streamId, begun.ephemeralPublicKey);
|
||
|
|
const aliceStream = await begun.complete(mAccept.ephemeralPublicKey);
|
||
|
|
const frame = await aliceStream.seal(enc.encode('secret'));
|
||
|
|
await expect(mAccept.stream.open(frame)).rejects.toBeInstanceOf(DecryptionError);
|
||
|
|
});
|
||
|
|
});
|