release(v4.11.0): streaming Double-Ratchet sub-sessions (ShadeStream)
Some checks failed
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Test / test (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

Answers Vyvern FR shade-ws-streaming-ratchet.md with a first-class
streaming-session API rather than the documented-contract fallback.
The Double-Ratchet crypto was already safe for high-frequency
one-directional use; the send/receive wrapper was not (per-frame
saveSession keystore write; shared per-peer mutex + single stored
session row coupling reuse to the HTTP path).

- @shade/core: stream.ts — identity-bound 3-DH seeding (X3DH-minus-
  prekeys, no prekey-server round trip, mutually authenticated against
  the parent session's pinned identities), bootstrapStreamSession
  reusing init{Sender,Receiver}Session verbatim, in-memory-only
  StreamRatchet (own op-mutex, never persisted, zeroized on close).
  beginStream/acceptStream on ShadeSessionManager; Stream{Closed,
  Handshake}Error; stream.opened/closed events.
- @shade/proto: STREAM_OPEN/OPEN_ACK/FRAME wire (0x31/0x32/0x33),
  additive; inspectEnvelopeType extended.
- @shade/sdk: Shade.openStream/acceptStream → ShadeStream
  (handshakeFrame/handleHandshake/seal/open/close), transport-
  agnostic, independent of encrypt/decrypt queues + parent session,
  identical server (sqlite:) and browser (IndexedDB) — touches no
  storage.
- Tests: 5000-frame one-directional burst (bounded skipped keys + FS
  zeroize), parent-session independence, replay/rewind rejection,
  mutual-auth, proto wire round-trips. Full suite green (1159 pass).
- docs/streaming-sessions.md (R1–R7 contract); SECURITY.md matrix rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 11:29:09 +02:00
parent 188c3db56a
commit 037f994572
39 changed files with 1241 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/core",
"version": "4.10.0",
"version": "4.11.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -107,6 +107,31 @@ export class FingerprintNotVerifiedError extends ShadeError {
}
}
/**
* Thrown when `seal()` / `open()` is called on a {@link StreamRatchet}
* that has already been torn down via `close()`. The stream's ratchet
* secrets have been zeroized and cannot be revived — open a fresh
* stream instead.
*/
export class StreamClosedError extends ShadeError {
constructor(message = 'Stream is closed') {
super('SHADE_STREAM_CLOSED', message);
this.name = 'StreamClosedError';
}
}
/**
* Thrown when a stream handshake frame is malformed, arrives in the
* wrong order, or references a streamId that does not match the stream
* it was fed to.
*/
export class StreamHandshakeError extends ShadeError {
constructor(message = 'Stream handshake failed') {
super('SHADE_STREAM_HANDSHAKE', message);
this.name = 'StreamHandshakeError';
}
}
// ─── Infrastructure Errors ───────────────────────────────────
export class NetworkError extends ShadeError {

View File

@@ -36,6 +36,10 @@ export interface ShadeEventMap {
'signed_prekey.rotated': { oldKeyId: number; newKeyId: number };
'trust.pinned': { address: string; identityKeyHash: string };
'trust.changed': { address: string; oldKeyHash: string; newKeyHash: string };
/** V4.11 — a streaming sub-ratchet handshake completed. */
'stream.opened': { address: string; role: 'initiator' | 'responder' };
/** V4.11 — a streaming sub-ratchet was torn down and zeroized. */
'stream.closed': { address: string };
}
export type ShadeEventName = keyof ShadeEventMap;

View File

@@ -5,6 +5,7 @@ export * from './keys.js';
export * from './errors.js';
export * from './x3dh.js';
export * from './ratchet.js';
export * from './stream.js';
export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js';
export * from './serialization.js';
export * from './fingerprint.js';

View File

@@ -23,7 +23,14 @@ import {
ratchetEncrypt,
ratchetDecrypt,
} from './ratchet.js';
import { NoSessionError } from './errors.js';
import {
deriveStreamRootKey,
bootstrapStreamSession,
StreamRatchet,
STREAM_ID_BYTES,
STREAM_EPHEMERAL_BYTES,
} from './stream.js';
import { NoSessionError, StreamHandshakeError } from './errors.js';
import { computeFingerprint, shortFingerprint } from './fingerprint.js';
import { ShadeEventEmitter, shortHash } from './events.js';
import {
@@ -626,6 +633,121 @@ export class ShadeSessionManager {
return dec.decode(plaintext);
}
// ─── Streaming sub-sessions (V4.11) ────────────────────────
/**
* Resolve the peer's pinned identity X25519 key for a stream
* handshake. Requires an *already established* parent session — the
* stream is explicitly a "second channel on a known peer", never a
* first contact (so it needs no prekey-server round trip and inherits
* the parent's TOFU pin).
*/
private async streamIdentityMaterial(
address: string,
): Promise<{ selfIdentityDHPriv: Uint8Array; peerIdentityDHPub: Uint8Array }> {
if (!this.identity) throw new Error('Not initialized');
const session = await this.storage.getSession(address);
if (!session) throw new NoSessionError(address);
return {
selfIdentityDHPriv: this.identity.dhPrivateKey,
peerIdentityDHPub: session.remoteIdentityKey,
};
}
/**
* Initiator side of a stream handshake. Generates the streamId and
* this side's ephemeral, and returns a `complete` continuation that
* derives the sub-ratchet once the responder's ephemeral arrives in
* the `STREAM_OPEN_ACK`.
*
* Touches neither the stored parent session nor the per-peer op
* queues (R5).
*/
async beginStream(address: string): Promise<{
streamId: Uint8Array;
ephemeralPublicKey: Uint8Array;
complete: (peerEphemeralPub: Uint8Array) => Promise<StreamRatchet>;
}> {
const { selfIdentityDHPriv, peerIdentityDHPub } =
await this.streamIdentityMaterial(address);
const streamId = this.crypto.randomBytes(STREAM_ID_BYTES);
const ephemeral = await this.crypto.generateX25519KeyPair();
const complete = async (peerEphemeralPub: Uint8Array): Promise<StreamRatchet> => {
if (peerEphemeralPub.length !== STREAM_EPHEMERAL_BYTES) {
throw new StreamHandshakeError(
`responder ephemeral must be ${STREAM_EPHEMERAL_BYTES} bytes`,
);
}
const sk = await deriveStreamRootKey(
this.crypto,
'initiator',
streamId,
selfIdentityDHPriv,
peerIdentityDHPub,
ephemeral.privateKey,
peerEphemeralPub,
);
const session = await bootstrapStreamSession(this.crypto, 'initiator', sk, peerIdentityDHPub, {
publicKey: peerEphemeralPub,
privateKey: new Uint8Array(0),
});
this.crypto.zeroize(sk);
this.crypto.zeroize(ephemeral.privateKey);
this.events?.emit('stream.opened', { address, role: 'initiator' });
return new StreamRatchet(this.crypto, session, streamId);
};
return { streamId, ephemeralPublicKey: ephemeral.publicKey, complete };
}
/**
* Responder side of a stream handshake. Given the initiator's
* `STREAM_OPEN` (its streamId + ephemeral), derives the sub-ratchet
* immediately and returns this side's ephemeral for the
* `STREAM_OPEN_ACK`.
*/
async acceptStream(
address: string,
streamId: Uint8Array,
initiatorEphemeralPub: Uint8Array,
): Promise<{ ephemeralPublicKey: Uint8Array; stream: StreamRatchet }> {
if (streamId.length !== STREAM_ID_BYTES) {
throw new StreamHandshakeError(`streamId must be ${STREAM_ID_BYTES} bytes`);
}
if (initiatorEphemeralPub.length !== STREAM_EPHEMERAL_BYTES) {
throw new StreamHandshakeError(
`initiator ephemeral must be ${STREAM_EPHEMERAL_BYTES} bytes`,
);
}
const { selfIdentityDHPriv, peerIdentityDHPub } =
await this.streamIdentityMaterial(address);
const ephemeral = await this.crypto.generateX25519KeyPair();
const sk = await deriveStreamRootKey(
this.crypto,
'responder',
streamId,
selfIdentityDHPriv,
peerIdentityDHPub,
ephemeral.privateKey,
initiatorEphemeralPub,
);
const session = await bootstrapStreamSession(
this.crypto,
'responder',
sk,
peerIdentityDHPub,
ephemeral,
);
this.crypto.zeroize(sk);
this.events?.emit('stream.opened', { address, role: 'responder' });
return {
ephemeralPublicKey: ephemeral.publicKey,
stream: new StreamRatchet(this.crypto, session, streamId),
};
}
}
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {

View File

@@ -0,0 +1,233 @@
import type { CryptoProvider } from './crypto.js';
import type { KeyPair, RatchetMessage, SessionState } from './types.js';
import {
initSenderSession,
initReceiverSession,
ratchetEncrypt,
ratchetDecrypt,
} from './ratchet.js';
import { StreamClosedError } from './errors.js';
/**
* Streaming Double-Ratchet sub-sessions (V4.11).
*
* Wraps a long-lived, high-frequency, often-one-directional channel
* (e.g. a server→client WebSocket log burst) in an *independent* Double
* Ratchet that is derived from — but never mutates — an already
* established parent Shade session.
*
* Why a sub-ratchet rather than reusing `ShadeSessionManager`:
*
* - **Independence (R5).** A stream gets its own root key, chains, DH
* ratchet and op-mutex. Opening/closing it never touches the stored
* parent `SessionState` nor serialises against the HTTP send/receive
* queue.
* - **Performance (R7).** The stream ratchet lives only in memory and
* is *never* written to the keystore. There is therefore zero
* per-frame storage I/O — the cost is purely the symmetric KDF +
* AES-GCM, the same primitives the HTTP path uses.
* - **Forward secrecy.** Not persisting the evolving ratchet state is
* a feature, not a shortcut: writing per-frame secrets to disk would
* actively defeat the forward-secrecy guarantee. A dropped/reconnected
* stream is re-opened with a fresh handshake, not resumed.
*
* ## Seeding (no prekey-server round trip)
*
* The stream root key is derived from an identity-bound 3-DH exchange —
* the X3DH pattern minus the signed / one-time prekeys, because the
* peer's identity is *already* mutually pinned by the parent session's
* TOFU. Two ephemeral keys are exchanged inside the transport itself
* (`STREAM_OPEN` / `STREAM_OPEN_ACK`); no prekey server is involved.
*
* slotA = DH(initiatorEphemeral, responderIdentity) — auth of responder
* slotB = DH(initiatorIdentity, responderEphemeral) — auth of initiator
* slotC = DH(initiatorEphemeral, responderEphemeral) — ephemeral FS
*
* SK = HKDF(ikm = slotA‖slotB‖slotC, salt = streamId, info = "ShadeStream/v1")
*
* Both peers compute the identical three scalars regardless of role, so
* `SK` agrees. An attacker lacking the responder's identity private key
* cannot form slotA; one lacking the initiator's cannot form slotB —
* the handshake is therefore mutually authenticated against the same
* identities the parent session already trusts.
*
* `SK` then bootstraps a textbook Double Ratchet by handing the
* responder's ephemeral to {@link initSenderSession} /
* {@link initReceiverSession} exactly the way X3DH hands its signed
* prekey to the ratchet — so `ratchetEncrypt` / `ratchetDecrypt` (and
* thus every R1R3 guarantee they already carry) apply unchanged.
*/
export type StreamRole = 'initiator' | 'responder';
/** Stream identifier length (bytes). 128 bits of collision resistance. */
export const STREAM_ID_BYTES = 16;
/** Ephemeral X25519 public-key length carried in the handshake. */
export const STREAM_EPHEMERAL_BYTES = 32;
const STREAM_KDF_INFO = new TextEncoder().encode('ShadeStream/v1');
/**
* Derive the stream's independent root key from the identity-bound 3-DH
* exchange. Pure: never reads or mutates any `SessionState`.
*
* @param role which end of the handshake we are
* @param streamId 16-byte stream id (HKDF salt; binds the
* derivation so two concurrent streams to the
* same peer never share a root key)
* @param selfIdentityDHPriv our long-term identity X25519 private key
* @param peerIdentityDHPub peer's pinned identity X25519 public key
* (the value the parent session pinned)
* @param selfEphemeralPriv our per-stream ephemeral X25519 private key
* @param peerEphemeralPub peer's per-stream ephemeral X25519 public key
*/
export async function deriveStreamRootKey(
crypto: CryptoProvider,
role: StreamRole,
streamId: Uint8Array,
selfIdentityDHPriv: Uint8Array,
peerIdentityDHPub: Uint8Array,
selfEphemeralPriv: Uint8Array,
peerEphemeralPub: Uint8Array,
): Promise<Uint8Array> {
// Each slot is pinned to a fixed semantic (not to local role) so both
// sides feed HKDF the identical ikm:
// slotA = DH(initiatorEphemeral, responderIdentity)
// slotB = DH(initiatorIdentity, responderEphemeral)
// slotC = DH(initiatorEphemeral, responderEphemeral)
let slotA: Uint8Array;
let slotB: Uint8Array;
let slotC: Uint8Array;
if (role === 'initiator') {
slotA = await crypto.x25519(selfEphemeralPriv, peerIdentityDHPub);
slotB = await crypto.x25519(selfIdentityDHPriv, peerEphemeralPub);
slotC = await crypto.x25519(selfEphemeralPriv, peerEphemeralPub);
} else {
slotA = await crypto.x25519(selfIdentityDHPriv, peerEphemeralPub);
slotB = await crypto.x25519(selfEphemeralPriv, peerIdentityDHPub);
slotC = await crypto.x25519(selfEphemeralPriv, peerEphemeralPub);
}
const ikm = new Uint8Array(96);
ikm.set(slotA, 0);
ikm.set(slotB, 32);
ikm.set(slotC, 64);
const sk = await crypto.hkdf(ikm, streamId, STREAM_KDF_INFO, 32);
crypto.zeroize(slotA);
crypto.zeroize(slotB);
crypto.zeroize(slotC);
crypto.zeroize(ikm);
return sk;
}
/**
* Bootstrap a fresh Double Ratchet `SessionState` from the derived
* stream root key. The responder's ephemeral plays exactly the role
* X3DH's signed prekey plays in {@link initSenderSession} /
* {@link initReceiverSession}, so the ratchet handoff is identical to
* the proven HTTP path.
*
* On the initiator only `responderEphemeral.publicKey` is needed; the
* responder must pass its full ephemeral keypair.
*
* `peerIdentityDHPub` is recorded as the session's `remoteIdentityKey`
* so stream fingerprints stay meaningful and consistent with the parent.
*/
export async function bootstrapStreamSession(
crypto: CryptoProvider,
role: StreamRole,
sk: Uint8Array,
peerIdentityDHPub: Uint8Array,
responderEphemeral: KeyPair,
): Promise<SessionState> {
if (role === 'initiator') {
// initSenderSession derives a fresh root via kdfRootKey and does not
// retain `sk`, so the caller may safely zeroize it afterwards.
return initSenderSession(crypto, sk, peerIdentityDHPub, responderEphemeral.publicKey);
}
// initReceiverSession stores the root key BY REFERENCE. Hand it an
// independent copy so the caller zeroizing its `sk` scratch buffer
// can't wipe the live session root.
return initReceiverSession(new Uint8Array(sk), peerIdentityDHPub, responderEphemeral);
}
/** Zeroize every secret a stream session holds, then drop the chains. */
function zeroizeSession(crypto: CryptoProvider, s: SessionState): void {
crypto.zeroize(s.rootKey);
if (s.sendChain.chainKey.length > 0) crypto.zeroize(s.sendChain.chainKey);
if (s.receiveChain && s.receiveChain.chainKey.length > 0) {
crypto.zeroize(s.receiveChain.chainKey);
}
if (s.dhSend.privateKey.length > 0) crypto.zeroize(s.dhSend.privateKey);
for (const mk of s.skippedKeys.values()) crypto.zeroize(mk);
s.skippedKeys.clear();
}
/**
* In-memory holder for a stream's Double Ratchet. Serialises its own
* `seal`/`open`/`close` on a private promise chain (independent of the
* SDK's per-peer encrypt/decrypt queues — R5) so per-frame ratchet
* mutations never interleave, while staying fully concurrent with the
* parent session and with other streams.
*
* Never persisted: the ratchet exists only for the lifetime of the
* stream and is zeroized on `close()`.
*/
export class StreamRatchet {
private session: SessionState | null;
private opChain: Promise<unknown> = Promise.resolve();
constructor(
private readonly crypto: CryptoProvider,
session: SessionState,
/** 16-byte stream id this ratchet is bound to. */
public readonly streamId: Uint8Array,
) {
this.session = session;
}
/** True once {@link close} has run; `seal`/`open` will throw. */
get closed(): boolean {
return this.session === null;
}
private run<T>(fn: (s: SessionState) => Promise<T>): Promise<T> {
const next = this.opChain.catch(() => undefined).then(() => {
if (!this.session) throw new StreamClosedError();
return fn(this.session);
});
// Keep a never-rejecting tail so a failed frame doesn't poison the
// next one (a single bad inbound frame must not wedge the stream).
this.opChain = next.catch(() => undefined);
return next;
}
/** Wrap one logical frame. Advances the sending chain by one step. */
seal(plaintext: Uint8Array): Promise<RatchetMessage> {
return this.run((s) => ratchetEncrypt(this.crypto, s, plaintext));
}
/**
* Unwrap one inbound frame. Correct and memory-bounded across long
* one-directional runs from the peer: ordered transport delivery
* skips zero keys per frame, and out-of-order arrivals are still
* capped by the ratchet's `MAX_SKIP` / `MAX_CACHED_SKIPPED_KEYS`.
*/
open(message: RatchetMessage): Promise<Uint8Array> {
return this.run((s) => ratchetDecrypt(this.crypto, s, message));
}
/** Zeroize and drop the ratchet. Idempotent. */
close(): Promise<void> {
return this.opChain
.catch(() => undefined)
.then(() => {
if (this.session) {
zeroizeSession(this.crypto, this.session);
this.session = null;
}
});
}
}

View File

@@ -0,0 +1,176 @@
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);
});
});