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
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:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
233
packages/shade-core/src/stream.ts
Normal file
233
packages/shade-core/src/stream.ts
Normal 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 R1–R3 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
176
packages/shade-core/tests/stream.test.ts
Normal file
176
packages/shade-core/tests/stream.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user