feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
M-Files-1..6 land the full files-RPC layer + everything 0.3.0 needs to
ship. Apps keep their own UI; this layer ships the typed RPC, the
streams bridge for content I/O, and production hooks (rate limit,
retention, fingerprint gate, metrics).
@shade/files (NEW)
- Standard ops: list/stat/mkdir/delete/move/read/write/getThumbnail with
Zod-validated wire schemas + clean user-handler types.
- Custom ops: typed via TypeScript declaration merging on CustomOpsMap
+ per-op Zod schemas; client.custom('app.foo', {...}) is fully typed.
- Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams
(> 256 KiB) ride @shade/transfer via userMetadata.shadeFilesWriteId
/ shadeFilesReadStreamId correlation. Server-side TransformStream
bridges accept inbound transfers immediately (engine rejects chunks
that arrive before accept) and park the readable for the matching
RPC.
- Directory ops: walk(path, opts) async-iterable depth-first walker;
uploadDirectory()/downloadDirectory() with bounded concurrency pool
(default 4, cap 16), aggregated progress, abort.
- Production hooks (callback-based, vendor-neutral): rate-limit (op +
byte), idempotency cache (LRU + TTL + in-flight de-dupe), path
policy (traversal + percent-decode hardening), fingerprint gate
(required/optional/reject), pluggable Ed25519 sig verification with
±5 min replay window, onMetric sink (standard names).
- React hooks (subpath @shade/files/react): ShadeFilesProvider,
useShadeFiles, useFileList, useFileTransfer/Upload/Download.
- Shade.files.serve(handler) + Shade.files.client(peer) high-level
entrypoint in @shade/sdk; lazy + memoized; one handler per Shade.
Wire format bump
- @shade/proto wire VERSION 0x01 → 0x02. Length prefixes changed from
u16 to u32. The previous u16 silently truncated payloads above
64 KiB — a hard correctness ceiling that blocked inline file ops
up to 256 KiB. Wire-incompatible with 0.2.x peers; new sessions
only. Cross-platform Kotlin port (android/shade-android) updated to
match; test-vectors/wire-format.json regenerated.
Concurrency safety
- ShadeSessionManager.encrypt/.decrypt now run under per-peer mutex.
Concurrent decryptions of the same peer raced ratchet state
(manifested as sporadic "Failed to decrypt — wrong key or tampered
data" under load — surfaced once concurrent uploadDirectory pumped
many writes in flight). Encrypt was already serialized via
Shade.send's encryptChains; decrypt is now serialized at the
manager layer too.
@shade/streams extension
- StreamMetadata.userMetadata?: Record<string, string> for
application-level key/value pairs that round-trip verbatim through
stream-init plaintext. Used by @shade/files for write/read
correlation; available to any consumer.
@shade/sdk extension
- Shade.files getter (lazy + memoized).
- BackgroundHooks.onPruneFiles + periodic timer (default 5 min) +
BackgroundTasks.setHook(name, fn) for runtime hook registration.
Bundles in-flight 0.2.0 work
- packages/shade-streams/, packages/shade-transfer/, related
shade-sdk streams-bridge + shade-widgets transfer hooks were
uncommitted prior to this session. Including them keeps the
workspace consistent at 0.3.0 since @shade/files depends on them.
Tests
- 74 new tests in @shade/files (572 → 646 workspace pass; 0 fail;
3× stable). Coverage spans unit (inline-threshold + concurrency),
integration (read-write inline + streams up to 1 MiB, walk +
upload/download directory, custom-op, metrics, SDK namespace
end-to-end), and security (tampered-envelope sig verification,
replay window, fingerprint gate, rate-limit + quota).
Release artifacts
- All packages bumped to 0.3.0 via scripts/bump-version.ts.
- scripts/publish-all.ts PACKAGES updated with shade-files in
topological order (after shade-transfer, before shade-sdk).
- bun run publish:dry clean (14 packed, 0 failed).
- examples/08-files-browser/ — three-process CLI demo (prekey + Bob
server + Alice CLI) covering list/stat/mkdir/delete/upload/download.
- docs/files.md — full API + design doc.
- CHANGELOG.md 0.3.0 entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
packages/shade-streams/package.json
Normal file
13
packages/shade-streams/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@shade/streams",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
"@shade/proto": "workspace:*"
|
||||
}
|
||||
}
|
||||
76
packages/shade-streams/src/aead.ts
Normal file
76
packages/shade-streams/src/aead.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { StreamDecryptionError } from './errors.js';
|
||||
import { STREAM_NONCE_BYTES } from './nonce.js';
|
||||
|
||||
/** Authentication tag length for AES-256-GCM (always 16 bytes). */
|
||||
export const AEAD_TAG_BYTES = 16;
|
||||
|
||||
/**
|
||||
* SubtleCrypto-style typed buffer source bridge. The DOM `BufferSource` alias
|
||||
* isn't available without `lib: ["DOM"]`, but SubtleCrypto runtime accepts
|
||||
* `ArrayBuffer` or `ArrayBufferView` interchangeably. Cast through `unknown`
|
||||
* to satisfy TS without dragging in DOM lib (matches the pattern in
|
||||
* `@shade/crypto-web/src/provider.ts:14`).
|
||||
*/
|
||||
function bs(u: Uint8Array): ArrayBuffer {
|
||||
return u as unknown as ArrayBuffer;
|
||||
}
|
||||
|
||||
function resolveSubtle(subtle?: SubtleCrypto): SubtleCrypto {
|
||||
return subtle ?? globalThis.crypto.subtle;
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM encrypt with a CALLER-SUPPLIED 12-byte nonce.
|
||||
*
|
||||
* Unlike `CryptoProvider.aesGcmEncrypt` (which generates a random nonce
|
||||
* internally), streams require deterministic nonces derived from
|
||||
* `(laneId, seq)`. Returns the ciphertext concatenated with the 16-byte
|
||||
* authentication tag (SubtleCrypto's standard layout).
|
||||
*/
|
||||
export async function aesGcmEncryptWithNonce(
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
aad: Uint8Array,
|
||||
subtle?: SubtleCrypto,
|
||||
): Promise<Uint8Array> {
|
||||
if (nonce.length !== STREAM_NONCE_BYTES) {
|
||||
throw new Error(`AES-GCM nonce must be ${STREAM_NONCE_BYTES} bytes`);
|
||||
}
|
||||
const s = resolveSubtle(subtle);
|
||||
const aesKey = await s.importKey('raw', bs(key), 'AES-GCM', false, ['encrypt']);
|
||||
const out = await s.encrypt(
|
||||
{ name: 'AES-GCM', iv: bs(nonce), additionalData: bs(aad) },
|
||||
aesKey,
|
||||
bs(plaintext),
|
||||
);
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM decrypt with a CALLER-SUPPLIED nonce. Throws
|
||||
* `StreamDecryptionError` on authentication failure.
|
||||
*/
|
||||
export async function aesGcmDecryptWithNonce(
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
ciphertext: Uint8Array,
|
||||
aad: Uint8Array,
|
||||
subtle?: SubtleCrypto,
|
||||
): Promise<Uint8Array> {
|
||||
if (nonce.length !== STREAM_NONCE_BYTES) {
|
||||
throw new Error(`AES-GCM nonce must be ${STREAM_NONCE_BYTES} bytes`);
|
||||
}
|
||||
const s = resolveSubtle(subtle);
|
||||
const aesKey = await s.importKey('raw', bs(key), 'AES-GCM', false, ['decrypt']);
|
||||
try {
|
||||
const out = await s.decrypt(
|
||||
{ name: 'AES-GCM', iv: bs(nonce), additionalData: bs(aad) },
|
||||
aesKey,
|
||||
bs(ciphertext),
|
||||
);
|
||||
return new Uint8Array(out);
|
||||
} catch {
|
||||
throw new StreamDecryptionError();
|
||||
}
|
||||
}
|
||||
265
packages/shade-streams/src/coordinator.ts
Normal file
265
packages/shade-streams/src/coordinator.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import { decodeStreamChunk } from '@shade/proto';
|
||||
import { StreamFinishedError, StreamProtocolError } from './errors.js';
|
||||
import { StreamingSha256 } from './hash.js';
|
||||
import { StreamReceiver } from './receiver.js';
|
||||
import { StreamSender } from './sender.js';
|
||||
import type { DecryptedChunk, EncryptedChunk, LaneInitSpec } from './types.js';
|
||||
|
||||
export interface MultiLaneSenderInit {
|
||||
crypto: CryptoProvider;
|
||||
subtle?: SubtleCrypto;
|
||||
streamId: Uint8Array;
|
||||
streamSecret: Uint8Array;
|
||||
lanes: LaneInitSpec[];
|
||||
/** Per-lane resume offsets; defaults to 0 for each lane. */
|
||||
startSeqByLane?: Map<number, number | bigint>;
|
||||
}
|
||||
|
||||
export interface MultiLaneReceiverInit extends MultiLaneSenderInit {}
|
||||
|
||||
/** Per-lane sha256 fingerprint for the stream-finish envelope. */
|
||||
export interface LaneFingerprint {
|
||||
laneId: number;
|
||||
/** sha256 over plaintext bytes carried by this lane in seq order. */
|
||||
sha256: Uint8Array;
|
||||
chunkCount: number;
|
||||
byteCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-lane stream sender.
|
||||
*
|
||||
* Wraps N independent `StreamSender`s sharing the same (streamSecret, streamId)
|
||||
* and tracks an `overallSha256` over the original plaintext as the consumer
|
||||
* dispatches bytes to lanes.
|
||||
*
|
||||
* The coordinator is partition-AGNOSTIC — the consumer (transfer layer)
|
||||
* decides which bytes go to which lane. The coordinator only enforces:
|
||||
* - lane lookup by laneId
|
||||
* - update of overallSha256 from `appendOverall(...)` (called BEFORE bytes
|
||||
* are dispatched to lanes, in original byte order)
|
||||
*/
|
||||
export class MultiLaneSender {
|
||||
private readonly senders = new Map<number, StreamSender>();
|
||||
private readonly overallHasher = new StreamingSha256();
|
||||
private destroyed = false;
|
||||
|
||||
private constructor(senders: StreamSender[]) {
|
||||
for (const s of senders) {
|
||||
this.senders.set(s.laneId, s);
|
||||
}
|
||||
}
|
||||
|
||||
static async create(opts: MultiLaneSenderInit): Promise<MultiLaneSender> {
|
||||
if (opts.lanes.length === 0) {
|
||||
throw new ValidationError('at least one lane is required', 'lanes');
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
for (const l of opts.lanes) {
|
||||
if (seen.has(l.laneId)) {
|
||||
throw new ValidationError(`duplicate laneId ${l.laneId}`, 'lanes');
|
||||
}
|
||||
seen.add(l.laneId);
|
||||
}
|
||||
const senders: StreamSender[] = [];
|
||||
for (const lane of opts.lanes) {
|
||||
const startSeq = opts.startSeqByLane?.get(lane.laneId);
|
||||
senders.push(
|
||||
await StreamSender.create({
|
||||
crypto: opts.crypto,
|
||||
...(opts.subtle !== undefined ? { subtle: opts.subtle } : {}),
|
||||
streamId: opts.streamId,
|
||||
streamSecret: opts.streamSecret,
|
||||
laneId: lane.laneId,
|
||||
...(startSeq !== undefined ? { startSeq } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new MultiLaneSender(senders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the overall (cross-lane, original-order) sha256 hasher.
|
||||
*
|
||||
* The consumer must call this once with each plaintext slice IN ORIGINAL
|
||||
* BYTE ORDER (regardless of which lane the slice ends up on). For range
|
||||
* partitioning this is straightforward (lanes are contiguous); for
|
||||
* round-robin the consumer hashes each chunk in the order it was read
|
||||
* from the input.
|
||||
*/
|
||||
appendOverall(plaintext: Uint8Array): void {
|
||||
if (this.destroyed) throw new StreamFinishedError('MultiLaneSender: destroyed');
|
||||
this.overallHasher.update(plaintext);
|
||||
}
|
||||
|
||||
/** Encrypt `plaintext` for the given lane. */
|
||||
async encryptForLane(
|
||||
laneId: number,
|
||||
plaintext: Uint8Array,
|
||||
isLast: boolean,
|
||||
): Promise<EncryptedChunk> {
|
||||
const lane = this.senders.get(laneId);
|
||||
if (lane === undefined) {
|
||||
throw new StreamProtocolError(`Unknown laneId ${laneId}`);
|
||||
}
|
||||
return lane.encryptChunk(plaintext, isLast);
|
||||
}
|
||||
|
||||
/** Resume helper: feed plaintext into a lane's hasher without advancing seq. */
|
||||
preHashForLane(laneId: number, plaintext: Uint8Array): void {
|
||||
const lane = this.senders.get(laneId);
|
||||
if (lane === undefined) {
|
||||
throw new StreamProtocolError(`Unknown laneId ${laneId}`);
|
||||
}
|
||||
lane.preHash(plaintext);
|
||||
}
|
||||
|
||||
/** Snapshot per-lane fingerprints (call once when all lanes are finished). */
|
||||
getLaneFingerprints(): LaneFingerprint[] {
|
||||
const out: LaneFingerprint[] = [];
|
||||
for (const [laneId, sender] of this.senders) {
|
||||
out.push({
|
||||
laneId,
|
||||
sha256: sender.getLaneSha256Digest(),
|
||||
chunkCount: Number(sender.nextSequence),
|
||||
byteCount: Number(sender.bytesSent),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => a.laneId - b.laneId);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Snapshot overall sha256 over original-order plaintext. */
|
||||
getOverallSha256(): Uint8Array {
|
||||
return this.overallHasher.digest();
|
||||
}
|
||||
|
||||
/** All lanes have emitted their `isLast` chunks. */
|
||||
get allLanesFinished(): boolean {
|
||||
for (const lane of this.senders.values()) {
|
||||
if (!lane.isFinished) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
for (const sender of this.senders.values()) sender.destroy();
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-lane stream receiver.
|
||||
*
|
||||
* Routes incoming wire envelopes to per-lane `StreamReceiver`s by laneId,
|
||||
* and exposes a hook for the consumer to update the overall sha256 in
|
||||
* original byte order (the consumer's reorder logic feeds plaintext here
|
||||
* after collecting it from individual lanes).
|
||||
*/
|
||||
export class MultiLaneReceiver {
|
||||
private readonly receivers = new Map<number, StreamReceiver>();
|
||||
private readonly overallHasher = new StreamingSha256();
|
||||
private destroyed = false;
|
||||
|
||||
private constructor(receivers: StreamReceiver[]) {
|
||||
for (const r of receivers) {
|
||||
this.receivers.set(r.laneId, r);
|
||||
}
|
||||
}
|
||||
|
||||
static async create(opts: MultiLaneReceiverInit): Promise<MultiLaneReceiver> {
|
||||
if (opts.lanes.length === 0) {
|
||||
throw new ValidationError('at least one lane is required', 'lanes');
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
for (const l of opts.lanes) {
|
||||
if (seen.has(l.laneId)) {
|
||||
throw new ValidationError(`duplicate laneId ${l.laneId}`, 'lanes');
|
||||
}
|
||||
seen.add(l.laneId);
|
||||
}
|
||||
const receivers: StreamReceiver[] = [];
|
||||
for (const lane of opts.lanes) {
|
||||
const startSeq = opts.startSeqByLane?.get(lane.laneId);
|
||||
receivers.push(
|
||||
await StreamReceiver.create({
|
||||
crypto: opts.crypto,
|
||||
...(opts.subtle !== undefined ? { subtle: opts.subtle } : {}),
|
||||
streamId: opts.streamId,
|
||||
streamSecret: opts.streamSecret,
|
||||
laneId: lane.laneId,
|
||||
...(startSeq !== undefined ? { startSeq } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new MultiLaneReceiver(receivers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt one wire-level chunk. Routes to the lane indicated by the
|
||||
* envelope's laneId. Throws if no receiver exists for that laneId.
|
||||
*/
|
||||
async decryptChunk(wireBytes: Uint8Array): Promise<DecryptedChunk & { laneId: number }> {
|
||||
if (this.destroyed) throw new StreamFinishedError('MultiLaneReceiver: destroyed');
|
||||
const env = decodeStreamChunk(wireBytes);
|
||||
const lane = this.receivers.get(env.laneId);
|
||||
if (lane === undefined) {
|
||||
throw new StreamProtocolError(`Unknown laneId ${env.laneId}`);
|
||||
}
|
||||
const dec = await lane.decryptChunk(wireBytes);
|
||||
return { ...dec, laneId: env.laneId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the overall sha256 hasher with plaintext IN ORIGINAL BYTE ORDER.
|
||||
* The consumer's reorder logic decides when to call this (as bytes leave
|
||||
* the reorder buffer in original order, OR for range-partitioning,
|
||||
* concatenated lane outputs in laneId order).
|
||||
*/
|
||||
appendOverall(plaintext: Uint8Array): void {
|
||||
if (this.destroyed) throw new StreamFinishedError('MultiLaneReceiver: destroyed');
|
||||
this.overallHasher.update(plaintext);
|
||||
}
|
||||
|
||||
/** Snapshot per-lane fingerprints (call once when all lanes are finished). */
|
||||
getLaneFingerprints(): LaneFingerprint[] {
|
||||
const out: LaneFingerprint[] = [];
|
||||
for (const [laneId, receiver] of this.receivers) {
|
||||
out.push({
|
||||
laneId,
|
||||
sha256: receiver.getLaneSha256Digest(),
|
||||
chunkCount: Number(receiver.nextExpectedSequence),
|
||||
byteCount: Number(receiver.bytesReceived),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => a.laneId - b.laneId);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Snapshot overall sha256 over original-order plaintext. */
|
||||
getOverallSha256(): Uint8Array {
|
||||
return this.overallHasher.digest();
|
||||
}
|
||||
|
||||
/** All lanes have received their `isLast` chunks. */
|
||||
get allLanesFinished(): boolean {
|
||||
for (const lane of this.receivers.values()) {
|
||||
if (!lane.isFinished) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Get a specific lane receiver (for inspection/resume). */
|
||||
getLane(laneId: number): StreamReceiver | undefined {
|
||||
return this.receivers.get(laneId);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
for (const receiver of this.receivers.values()) receiver.destroy();
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
103
packages/shade-streams/src/envelope.ts
Normal file
103
packages/shade-streams/src/envelope.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Plaintext schemas for stream control messages.
|
||||
*
|
||||
* Stream-init / -finish / -abort / -resume ride the existing 0x02 ratchet
|
||||
* envelope — Double Ratchet AEAD already authenticates them and rejects
|
||||
* replays. Each carries a JSON plaintext with a `kind` discriminator.
|
||||
* Stream-chunk uses dedicated wire type 0x11 (see `@shade/proto/wire.ts`).
|
||||
*/
|
||||
import { ValidationError } from '@shade/core';
|
||||
import type { LaneInitSpec, StreamMetadata } from './types.js';
|
||||
|
||||
export type StreamControlKind =
|
||||
| 'shade.stream-init/v1'
|
||||
| 'shade.stream-finish/v1'
|
||||
| 'shade.stream-abort/v1'
|
||||
| 'shade.stream-resume-request/v1'
|
||||
| 'shade.stream-resume-state/v1';
|
||||
|
||||
export interface StreamInitMessage {
|
||||
kind: 'shade.stream-init/v1';
|
||||
/** base64url-encoded 16-byte streamId. */
|
||||
streamId: string;
|
||||
/** base64url-encoded 32-byte streamSecret. */
|
||||
streamSecret: string;
|
||||
metadata: StreamMetadata;
|
||||
lanes: LaneInitSpec[];
|
||||
}
|
||||
|
||||
export interface StreamFinishMessage {
|
||||
kind: 'shade.stream-finish/v1';
|
||||
streamId: string;
|
||||
/** Per-lane integrity fingerprints, base64-encoded sha256 of plaintext bytes the lane carried. */
|
||||
laneSha256: Array<{
|
||||
laneId: number;
|
||||
sha256: string;
|
||||
chunkCount: number;
|
||||
byteCount: number;
|
||||
}>;
|
||||
/** base64-encoded sha256 over the original plaintext file in original byte order. */
|
||||
overallSha256: string;
|
||||
finishedAt: number;
|
||||
}
|
||||
|
||||
export type StreamAbortReason = 'sender-cancel' | 'receiver-cancel' | 'fatal-error';
|
||||
|
||||
export interface StreamAbortMessage {
|
||||
kind: 'shade.stream-abort/v1';
|
||||
streamId: string;
|
||||
reason: StreamAbortReason;
|
||||
message?: string;
|
||||
abortedAt: number;
|
||||
}
|
||||
|
||||
export interface StreamResumeRequestMessage {
|
||||
kind: 'shade.stream-resume-request/v1';
|
||||
streamId: string;
|
||||
requestedAt: number;
|
||||
}
|
||||
|
||||
export interface StreamResumeStateMessage {
|
||||
kind: 'shade.stream-resume-state/v1';
|
||||
streamId: string;
|
||||
/** lastSeqAcked = -1 means no chunk for this lane has been received yet. */
|
||||
lanes: Array<{ laneId: number; lastSeqAcked: number }>;
|
||||
resumedAt: number;
|
||||
}
|
||||
|
||||
export type StreamControlMessage =
|
||||
| StreamInitMessage
|
||||
| StreamFinishMessage
|
||||
| StreamAbortMessage
|
||||
| StreamResumeRequestMessage
|
||||
| StreamResumeStateMessage;
|
||||
|
||||
/** Type guard: does the value look like a stream control message? */
|
||||
export function isStreamControlMessage(value: unknown): value is StreamControlMessage {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const kind = (value as { kind?: unknown }).kind;
|
||||
if (typeof kind !== 'string') return false;
|
||||
return kind.startsWith('shade.stream-');
|
||||
}
|
||||
|
||||
/** Parse JSON plaintext; throw ValidationError on shape mismatch. */
|
||||
export function parseStreamControl(plaintext: string): StreamControlMessage {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
throw new ValidationError(
|
||||
`stream control plaintext is not valid JSON: ${(err as Error).message}`,
|
||||
'plaintext',
|
||||
);
|
||||
}
|
||||
if (!isStreamControlMessage(parsed)) {
|
||||
throw new ValidationError('plaintext is not a stream control message', 'plaintext');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Encode a stream control message as JSON; throws on circular refs. */
|
||||
export function encodeStreamControl(msg: StreamControlMessage): string {
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
53
packages/shade-streams/src/errors.ts
Normal file
53
packages/shade-streams/src/errors.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ShadeError } from '@shade/core';
|
||||
|
||||
export class StreamError extends ShadeError {
|
||||
constructor(code: string, message: string) {
|
||||
super(code, message);
|
||||
this.name = 'StreamError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamProtocolError extends StreamError {
|
||||
constructor(message: string) {
|
||||
super('SHADE_STREAM_PROTOCOL', message);
|
||||
this.name = 'StreamProtocolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamIntegrityError extends StreamError {
|
||||
constructor(message: string) {
|
||||
super('SHADE_STREAM_INTEGRITY', message);
|
||||
this.name = 'StreamIntegrityError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamReplayError extends StreamError {
|
||||
constructor(message = 'Stream chunk replay detected') {
|
||||
super('SHADE_STREAM_REPLAY', message);
|
||||
this.name = 'StreamReplayError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamOutOfOrderError extends StreamError {
|
||||
constructor(expected: number, received: number) {
|
||||
super(
|
||||
'SHADE_STREAM_OUT_OF_ORDER',
|
||||
`Out-of-order chunk: expected seq=${expected}, got ${received}`,
|
||||
);
|
||||
this.name = 'StreamOutOfOrderError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamDecryptionError extends StreamError {
|
||||
constructor(message = 'Stream chunk authenticated decryption failed') {
|
||||
super('SHADE_STREAM_DECRYPTION', message);
|
||||
this.name = 'StreamDecryptionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamFinishedError extends StreamError {
|
||||
constructor(message = 'Stream is already finished or aborted') {
|
||||
super('SHADE_STREAM_FINISHED', message);
|
||||
this.name = 'StreamFinishedError';
|
||||
}
|
||||
}
|
||||
41
packages/shade-streams/src/hash.ts
Normal file
41
packages/shade-streams/src/hash.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
|
||||
/**
|
||||
* Streaming SHA-256 wrapper.
|
||||
*
|
||||
* SubtleCrypto exposes only one-shot `digest`. Streams need incremental
|
||||
* hashing so the receiver doesn't materialize the full plaintext before
|
||||
* verifying integrity (NF2: O(chunkSize) memory). `@noble/hashes/sha2`
|
||||
* provides a streaming API that works in Bun, Node, and browsers without
|
||||
* any native bindings.
|
||||
*/
|
||||
export class StreamingSha256 {
|
||||
private h = sha256.create();
|
||||
private finalized = false;
|
||||
|
||||
/** Feed bytes into the hasher. No-op on empty input. */
|
||||
update(data: Uint8Array): this {
|
||||
if (this.finalized) throw new Error('StreamingSha256: already finalized');
|
||||
if (data.length > 0) this.h.update(data);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the 32-byte digest. After `digest()` the hasher is finalized;
|
||||
* subsequent `update()` calls will throw.
|
||||
*/
|
||||
digest(): Uint8Array {
|
||||
this.finalized = true;
|
||||
return this.h.digest();
|
||||
}
|
||||
|
||||
/** Whether `digest()` has been called. */
|
||||
get isFinalized(): boolean {
|
||||
return this.finalized;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: hash a single buffer in one shot. */
|
||||
export function sha256Once(data: Uint8Array): Uint8Array {
|
||||
return sha256(data);
|
||||
}
|
||||
47
packages/shade-streams/src/ids.ts
Normal file
47
packages/shade-streams/src/ids.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { ValidationError } from '@shade/core';
|
||||
|
||||
export const STREAM_ID_BYTES = 16;
|
||||
export const STREAM_SECRET_BYTES = 32;
|
||||
|
||||
/** Generate a fresh 16-byte random streamId. */
|
||||
export function generateStreamId(crypto: CryptoProvider): Uint8Array {
|
||||
return crypto.randomBytes(STREAM_ID_BYTES);
|
||||
}
|
||||
|
||||
/** Generate a fresh 32-byte random streamSecret. */
|
||||
export function generateStreamSecret(crypto: CryptoProvider): Uint8Array {
|
||||
return crypto.randomBytes(STREAM_SECRET_BYTES);
|
||||
}
|
||||
|
||||
/** Encode a streamId as URL-safe base64 (no padding). */
|
||||
export function streamIdToString(streamId: Uint8Array): string {
|
||||
if (streamId.length !== STREAM_ID_BYTES) {
|
||||
throw new ValidationError(`streamId must be ${STREAM_ID_BYTES} bytes`, 'streamId');
|
||||
}
|
||||
return base64UrlEncode(streamId);
|
||||
}
|
||||
|
||||
/** Decode a URL-safe base64 streamId back to bytes. */
|
||||
export function streamIdFromString(s: string): Uint8Array {
|
||||
const bytes = base64UrlDecode(s);
|
||||
if (bytes.length !== STREAM_ID_BYTES) {
|
||||
throw new ValidationError(`streamId must decode to ${STREAM_ID_BYTES} bytes`, 'streamId');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(s: string): Uint8Array {
|
||||
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const bin = atob(padded + pad);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
12
packages/shade-streams/src/index.ts
Normal file
12
packages/shade-streams/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './errors.js';
|
||||
export * from './types.js';
|
||||
export * from './ids.js';
|
||||
export * from './kdf.js';
|
||||
export * from './nonce.js';
|
||||
export * from './aead.js';
|
||||
export * from './hash.js';
|
||||
export * from './envelope.js';
|
||||
export * from './sender.js';
|
||||
export * from './receiver.js';
|
||||
export * from './partition.js';
|
||||
export * from './coordinator.js';
|
||||
66
packages/shade-streams/src/kdf.ts
Normal file
66
packages/shade-streams/src/kdf.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import { STREAM_ID_BYTES, STREAM_SECRET_BYTES } from './ids.js';
|
||||
|
||||
const TEXT = new TextEncoder();
|
||||
const STREAM_KEY_INFO = TEXT.encode('shade-stream/v1\0master');
|
||||
const LANE_KEY_INFO_PREFIX = TEXT.encode('shade-stream/v1\0lane\0');
|
||||
|
||||
const STREAM_KEY_BYTES = 32;
|
||||
export const LANE_KEY_BYTES = 32;
|
||||
|
||||
/**
|
||||
* Derive the master streamKey from a streamSecret + streamId.
|
||||
*
|
||||
* streamKey = HKDF(ikm=streamSecret, salt=streamId,
|
||||
* info="shade-stream/v1\0master", length=32)
|
||||
*
|
||||
* The streamKey is NEVER used to encrypt chunks directly — it is a root for
|
||||
* per-lane key derivation (see `deriveLaneKey`).
|
||||
*/
|
||||
export async function deriveStreamKey(
|
||||
crypto: CryptoProvider,
|
||||
streamSecret: Uint8Array,
|
||||
streamId: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
if (streamSecret.length !== STREAM_SECRET_BYTES) {
|
||||
throw new ValidationError(
|
||||
`streamSecret must be ${STREAM_SECRET_BYTES} bytes`,
|
||||
'streamSecret',
|
||||
);
|
||||
}
|
||||
if (streamId.length !== STREAM_ID_BYTES) {
|
||||
throw new ValidationError(`streamId must be ${STREAM_ID_BYTES} bytes`, 'streamId');
|
||||
}
|
||||
return crypto.hkdf(streamSecret, streamId, STREAM_KEY_INFO, STREAM_KEY_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a lane-specific AEAD key.
|
||||
*
|
||||
* laneKey[i] = HKDF(ikm=streamKey, salt=streamId,
|
||||
* info="shade-stream/v1\0lane\0" || u32_be(laneId), length=32)
|
||||
*
|
||||
* Distinct laneIds produce independent keys; receiver derives the same key
|
||||
* given the same (streamSecret, streamId, laneId).
|
||||
*/
|
||||
export async function deriveLaneKey(
|
||||
crypto: CryptoProvider,
|
||||
streamKey: Uint8Array,
|
||||
streamId: Uint8Array,
|
||||
laneId: number,
|
||||
): Promise<Uint8Array> {
|
||||
if (streamKey.length !== STREAM_KEY_BYTES) {
|
||||
throw new ValidationError(`streamKey must be ${STREAM_KEY_BYTES} bytes`, 'streamKey');
|
||||
}
|
||||
if (streamId.length !== STREAM_ID_BYTES) {
|
||||
throw new ValidationError(`streamId must be ${STREAM_ID_BYTES} bytes`, 'streamId');
|
||||
}
|
||||
if (!Number.isInteger(laneId) || laneId < 0 || laneId > 0xffff_ffff) {
|
||||
throw new ValidationError(`laneId must fit in u32: ${laneId}`, 'laneId');
|
||||
}
|
||||
const info = new Uint8Array(LANE_KEY_INFO_PREFIX.length + 4);
|
||||
info.set(LANE_KEY_INFO_PREFIX, 0);
|
||||
new DataView(info.buffer).setUint32(LANE_KEY_INFO_PREFIX.length, laneId, false);
|
||||
return crypto.hkdf(streamKey, streamId, info, LANE_KEY_BYTES);
|
||||
}
|
||||
62
packages/shade-streams/src/nonce.ts
Normal file
62
packages/shade-streams/src/nonce.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ValidationError } from '@shade/core';
|
||||
|
||||
export const STREAM_NONCE_BYTES = 12;
|
||||
|
||||
/** Maximum chunk seq value (u64 max). Hard-spec'd hard limit. */
|
||||
export const MAX_SEQ = 0xffff_ffff_ffff_ffffn;
|
||||
|
||||
/**
|
||||
* Construct the deterministic AES-GCM nonce for a stream chunk.
|
||||
*
|
||||
* nonce[0..4] = u32_be(laneId)
|
||||
* nonce[4..12] = u64_be(seq)
|
||||
*
|
||||
* Per (laneId, seq) is unique — combined with the lane-specific key, this
|
||||
* guarantees AES-GCM nonce-uniqueness even across multiple parallel lanes.
|
||||
*/
|
||||
export function buildChunkNonce(laneId: number, seq: number | bigint): Uint8Array {
|
||||
if (!Number.isInteger(laneId) || laneId < 0 || laneId > 0xffff_ffff) {
|
||||
throw new ValidationError(`laneId must fit in u32: ${laneId}`, 'laneId');
|
||||
}
|
||||
const seqBig = typeof seq === 'bigint' ? seq : BigInt(seq);
|
||||
if (seqBig < 0n || seqBig > MAX_SEQ) {
|
||||
throw new ValidationError(`seq must fit in u64 (>= 0): ${seq}`, 'seq');
|
||||
}
|
||||
const out = new Uint8Array(STREAM_NONCE_BYTES);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint32(0, laneId, false);
|
||||
view.setBigUint64(4, seqBig, false);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the AAD bound to a stream chunk. Computed implicitly on both sides
|
||||
* from the chunk header (never transmitted as-is). Tampering with any header
|
||||
* field invalidates the AEAD tag.
|
||||
*
|
||||
* aad = streamId(16) || u32_be(laneId) || u64_be(seq) || u8(isLast)
|
||||
*/
|
||||
export function buildChunkAad(
|
||||
streamId: Uint8Array,
|
||||
laneId: number,
|
||||
seq: number | bigint,
|
||||
isLast: boolean,
|
||||
): Uint8Array {
|
||||
if (streamId.length !== 16) {
|
||||
throw new ValidationError('streamId must be 16 bytes', 'streamId');
|
||||
}
|
||||
if (!Number.isInteger(laneId) || laneId < 0 || laneId > 0xffff_ffff) {
|
||||
throw new ValidationError(`laneId must fit in u32: ${laneId}`, 'laneId');
|
||||
}
|
||||
const seqBig = typeof seq === 'bigint' ? seq : BigInt(seq);
|
||||
if (seqBig < 0n || seqBig > MAX_SEQ) {
|
||||
throw new ValidationError(`seq must fit in u64 (>= 0): ${seq}`, 'seq');
|
||||
}
|
||||
const out = new Uint8Array(16 + 4 + 8 + 1);
|
||||
out.set(streamId, 0);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint32(16, laneId, false);
|
||||
view.setBigUint64(20, seqBig, false);
|
||||
out[28] = isLast ? 0x01 : 0x00;
|
||||
return out;
|
||||
}
|
||||
87
packages/shade-streams/src/partition.ts
Normal file
87
packages/shade-streams/src/partition.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ValidationError } from '@shade/core';
|
||||
import type { LaneInitSpec, LanePartition } from './types.js';
|
||||
|
||||
/** Build a range-partition plan: contiguous byte ranges, one per lane. */
|
||||
export function planRangePartition(
|
||||
totalBytes: number,
|
||||
laneCount: number,
|
||||
): LaneInitSpec[] {
|
||||
if (!Number.isInteger(totalBytes) || totalBytes < 0) {
|
||||
throw new ValidationError(`totalBytes must be a non-negative integer`, 'totalBytes');
|
||||
}
|
||||
if (!Number.isInteger(laneCount) || laneCount < 1) {
|
||||
throw new ValidationError(`laneCount must be >= 1`, 'laneCount');
|
||||
}
|
||||
const lanes: LaneInitSpec[] = [];
|
||||
// Use ceil so the first lanes get the extra byte when not evenly divisible.
|
||||
// Each lane's start = previous end. Last lane's end = totalBytes.
|
||||
const baseSize = Math.floor(totalBytes / laneCount);
|
||||
const remainder = totalBytes - baseSize * laneCount;
|
||||
let cursor = 0;
|
||||
for (let i = 0; i < laneCount; i++) {
|
||||
const extra = i < remainder ? 1 : 0;
|
||||
const size = baseSize + extra;
|
||||
const startByte = cursor;
|
||||
const endByte = cursor + size;
|
||||
lanes.push({
|
||||
laneId: i,
|
||||
partition: { kind: 'range', startByte, endByte, startChunk: 0 },
|
||||
});
|
||||
cursor = endByte;
|
||||
}
|
||||
return lanes;
|
||||
}
|
||||
|
||||
/** Build a round-robin partition plan: chunk i goes to lane (i mod count). */
|
||||
export function planRoundRobinPartition(laneCount: number): LaneInitSpec[] {
|
||||
if (!Number.isInteger(laneCount) || laneCount < 1) {
|
||||
throw new ValidationError(`laneCount must be >= 1`, 'laneCount');
|
||||
}
|
||||
const lanes: LaneInitSpec[] = [];
|
||||
for (let i = 0; i < laneCount; i++) {
|
||||
lanes.push({
|
||||
laneId: i,
|
||||
partition: { kind: 'round-robin', lane: i, count: laneCount },
|
||||
});
|
||||
}
|
||||
return lanes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an arbitrary byte range into chunkSize-sized slices.
|
||||
* Returns an array of [startByte, endByte) tuples for each chunk.
|
||||
*/
|
||||
export function chunkRange(
|
||||
startByte: number,
|
||||
endByte: number,
|
||||
chunkSize: number,
|
||||
): Array<{ start: number; end: number }> {
|
||||
if (chunkSize <= 0) {
|
||||
throw new ValidationError(`chunkSize must be positive`, 'chunkSize');
|
||||
}
|
||||
const out: Array<{ start: number; end: number }> = [];
|
||||
if (endByte === startByte) {
|
||||
// Empty range: emit one empty chunk so isLast can be carried.
|
||||
out.push({ start: startByte, end: startByte });
|
||||
return out;
|
||||
}
|
||||
for (let off = startByte; off < endByte; off += chunkSize) {
|
||||
out.push({ start: off, end: Math.min(off + chunkSize, endByte) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a `LanePartition` matches the global stream parameters.
|
||||
* Used by the receiver to detect partition-mismatch on resume.
|
||||
*/
|
||||
export function partitionsEqual(a: LanePartition, b: LanePartition): boolean {
|
||||
if (a.kind !== b.kind) return false;
|
||||
if (a.kind === 'range' && b.kind === 'range') {
|
||||
return a.startByte === b.startByte && a.endByte === b.endByte && a.startChunk === b.startChunk;
|
||||
}
|
||||
if (a.kind === 'round-robin' && b.kind === 'round-robin') {
|
||||
return a.lane === b.lane && a.count === b.count;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
169
packages/shade-streams/src/receiver.ts
Normal file
169
packages/shade-streams/src/receiver.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { ValidationError, constantTimeEqual } from '@shade/core';
|
||||
import { decodeStreamChunk } from '@shade/proto';
|
||||
import { aesGcmDecryptWithNonce } from './aead.js';
|
||||
import {
|
||||
StreamFinishedError,
|
||||
StreamOutOfOrderError,
|
||||
StreamProtocolError,
|
||||
StreamReplayError,
|
||||
} from './errors.js';
|
||||
import { StreamingSha256 } from './hash.js';
|
||||
import { deriveLaneKey, deriveStreamKey } from './kdf.js';
|
||||
import { buildChunkAad, buildChunkNonce, MAX_SEQ } from './nonce.js';
|
||||
import type { DecryptedChunk } from './types.js';
|
||||
|
||||
export interface StreamReceiverInit {
|
||||
crypto: CryptoProvider;
|
||||
subtle?: SubtleCrypto;
|
||||
streamId: Uint8Array;
|
||||
streamSecret: Uint8Array;
|
||||
laneId: number;
|
||||
/** First seq this receiver will accept; defaults to 0. Used for resume. */
|
||||
startSeq?: number | bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-lane stream receiver state machine.
|
||||
*
|
||||
* Verifies AEAD, enforces strict in-order seq (rejects replay + out-of-order),
|
||||
* and updates a running lane sha256 over the decrypted plaintext.
|
||||
*/
|
||||
export class StreamReceiver {
|
||||
private constructor(
|
||||
private readonly subtle: SubtleCrypto | undefined,
|
||||
private readonly crypto: CryptoProvider,
|
||||
private readonly streamIdBytes: Uint8Array,
|
||||
public readonly laneId: number,
|
||||
private laneKey: Uint8Array | null,
|
||||
private expectedSeq: bigint,
|
||||
private readonly hasher: StreamingSha256,
|
||||
private finished: boolean,
|
||||
private bytesReceivedInternal: bigint,
|
||||
) {}
|
||||
|
||||
/** 16-byte streamId this receiver decrypts under. Defensive copy. */
|
||||
get streamId(): Uint8Array {
|
||||
return this.streamIdBytes.slice();
|
||||
}
|
||||
|
||||
static async create(opts: StreamReceiverInit): Promise<StreamReceiver> {
|
||||
const streamKey = await deriveStreamKey(opts.crypto, opts.streamSecret, opts.streamId);
|
||||
try {
|
||||
const laneKey = await deriveLaneKey(opts.crypto, streamKey, opts.streamId, opts.laneId);
|
||||
const startSeq =
|
||||
opts.startSeq === undefined
|
||||
? 0n
|
||||
: typeof opts.startSeq === 'bigint'
|
||||
? opts.startSeq
|
||||
: BigInt(opts.startSeq);
|
||||
if (startSeq < 0n || startSeq > MAX_SEQ) {
|
||||
throw new ValidationError(`startSeq out of range: ${opts.startSeq}`, 'startSeq');
|
||||
}
|
||||
return new StreamReceiver(
|
||||
opts.subtle,
|
||||
opts.crypto,
|
||||
opts.streamId.slice(),
|
||||
opts.laneId,
|
||||
laneKey,
|
||||
startSeq,
|
||||
new StreamingSha256(),
|
||||
false,
|
||||
0n,
|
||||
);
|
||||
} finally {
|
||||
opts.crypto.zeroize(streamKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and authenticate a wire-level chunk envelope. Throws on:
|
||||
* - mismatched streamId / laneId
|
||||
* - replayed seq (already accepted)
|
||||
* - out-of-order seq (gap or backwards)
|
||||
* - tampered nonce / ciphertext / aad
|
||||
* - any chunk after `isLast`
|
||||
*/
|
||||
async decryptChunk(wireBytes: Uint8Array): Promise<DecryptedChunk> {
|
||||
if (this.finished) {
|
||||
throw new StreamFinishedError('StreamReceiver: lane already finished');
|
||||
}
|
||||
if (this.laneKey === null) {
|
||||
throw new StreamFinishedError('StreamReceiver: destroyed');
|
||||
}
|
||||
|
||||
const env = decodeStreamChunk(wireBytes);
|
||||
|
||||
if (!constantTimeEqual(env.streamId, this.streamIdBytes)) {
|
||||
throw new StreamProtocolError('stream-chunk streamId does not match this receiver');
|
||||
}
|
||||
if (env.laneId !== this.laneId) {
|
||||
throw new StreamProtocolError(
|
||||
`stream-chunk laneId=${env.laneId} routed to laneId=${this.laneId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const seqBig = typeof env.seq === 'bigint' ? env.seq : BigInt(env.seq);
|
||||
if (seqBig < this.expectedSeq) {
|
||||
throw new StreamReplayError(`Replay: seq=${seqBig}, already accepted < ${this.expectedSeq}`);
|
||||
}
|
||||
if (seqBig > this.expectedSeq) {
|
||||
throw new StreamOutOfOrderError(Number(this.expectedSeq), Number(seqBig));
|
||||
}
|
||||
|
||||
// Defense-in-depth: the wire nonce MUST equal the deterministically derived one.
|
||||
const expectedNonce = buildChunkNonce(this.laneId, seqBig);
|
||||
if (!constantTimeEqual(env.nonce, expectedNonce)) {
|
||||
throw new StreamProtocolError('stream-chunk nonce mismatch');
|
||||
}
|
||||
if (env.aad.length !== 0) {
|
||||
throw new StreamProtocolError('stream-chunk aad must be empty in v0.2.0');
|
||||
}
|
||||
|
||||
const aad = buildChunkAad(this.streamIdBytes, this.laneId, seqBig, env.isLast);
|
||||
const plaintext = await aesGcmDecryptWithNonce(
|
||||
this.laneKey,
|
||||
env.nonce,
|
||||
env.ciphertext,
|
||||
aad,
|
||||
this.subtle,
|
||||
);
|
||||
|
||||
this.hasher.update(plaintext);
|
||||
this.bytesReceivedInternal += BigInt(plaintext.length);
|
||||
this.expectedSeq = seqBig + 1n;
|
||||
|
||||
if (env.isLast) this.finished = true;
|
||||
|
||||
return { plaintext, seq: Number(seqBig), isLast: env.isLast };
|
||||
}
|
||||
|
||||
/** Snapshot the lane sha256 digest. Hasher is frozen after this call. */
|
||||
getLaneSha256Digest(): Uint8Array {
|
||||
return this.hasher.digest();
|
||||
}
|
||||
|
||||
/** Total plaintext bytes accepted so far in this lane. */
|
||||
get bytesReceived(): bigint {
|
||||
return this.bytesReceivedInternal;
|
||||
}
|
||||
|
||||
/** Next sequence number this receiver expects. */
|
||||
get nextExpectedSequence(): bigint {
|
||||
return this.expectedSeq;
|
||||
}
|
||||
|
||||
/** Has this lane received its `isLast` chunk? */
|
||||
get isFinished(): boolean {
|
||||
return this.finished;
|
||||
}
|
||||
|
||||
/** Zero the lane key in memory. After destroy, decrypt calls throw. */
|
||||
destroy(): void {
|
||||
if (this.laneKey !== null) {
|
||||
this.crypto.zeroize(this.laneKey);
|
||||
this.laneKey = null;
|
||||
}
|
||||
this.finished = true;
|
||||
}
|
||||
}
|
||||
165
packages/shade-streams/src/sender.ts
Normal file
165
packages/shade-streams/src/sender.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import { encodeStreamChunk } from '@shade/proto';
|
||||
import { aesGcmEncryptWithNonce } from './aead.js';
|
||||
import { StreamFinishedError } from './errors.js';
|
||||
import { StreamingSha256 } from './hash.js';
|
||||
import { deriveLaneKey, deriveStreamKey } from './kdf.js';
|
||||
import { buildChunkAad, buildChunkNonce, MAX_SEQ } from './nonce.js';
|
||||
import type { EncryptedChunk, StreamChunkEnvelope } from './types.js';
|
||||
|
||||
export interface StreamSenderInit {
|
||||
crypto: CryptoProvider;
|
||||
/** Optional SubtleCrypto for AEAD; defaults to globalThis.crypto.subtle. */
|
||||
subtle?: SubtleCrypto;
|
||||
streamId: Uint8Array;
|
||||
/** 32-byte streamSecret. The lane key is derived internally and stays in this instance. */
|
||||
streamSecret: Uint8Array;
|
||||
laneId: number;
|
||||
/** First seq this sender will emit; defaults to 0. Used for resume. */
|
||||
startSeq?: number | bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-lane stream sender state machine.
|
||||
*
|
||||
* Encapsulates the lane-specific AEAD key + monotonic seq counter + running
|
||||
* lane-sha256. Multiple senders (one per lane) sharing the same
|
||||
* `(streamSecret, streamId)` make up a parallel transfer.
|
||||
*/
|
||||
export class StreamSender {
|
||||
private constructor(
|
||||
private readonly subtle: SubtleCrypto | undefined,
|
||||
private readonly crypto: CryptoProvider,
|
||||
private readonly streamIdBytes: Uint8Array,
|
||||
public readonly laneId: number,
|
||||
private laneKey: Uint8Array | null,
|
||||
private nextSeq: bigint,
|
||||
private readonly hasher: StreamingSha256,
|
||||
private finished: boolean,
|
||||
private bytesSentInternal: bigint,
|
||||
) {}
|
||||
|
||||
/** 16-byte streamId this sender encrypts under. Defensive copy. */
|
||||
get streamId(): Uint8Array {
|
||||
return this.streamIdBytes.slice();
|
||||
}
|
||||
|
||||
static async create(opts: StreamSenderInit): Promise<StreamSender> {
|
||||
const streamKey = await deriveStreamKey(opts.crypto, opts.streamSecret, opts.streamId);
|
||||
try {
|
||||
const laneKey = await deriveLaneKey(opts.crypto, streamKey, opts.streamId, opts.laneId);
|
||||
const startSeq =
|
||||
opts.startSeq === undefined
|
||||
? 0n
|
||||
: typeof opts.startSeq === 'bigint'
|
||||
? opts.startSeq
|
||||
: BigInt(opts.startSeq);
|
||||
if (startSeq < 0n || startSeq > MAX_SEQ) {
|
||||
throw new ValidationError(`startSeq out of range: ${opts.startSeq}`, 'startSeq');
|
||||
}
|
||||
return new StreamSender(
|
||||
opts.subtle,
|
||||
opts.crypto,
|
||||
opts.streamId.slice(),
|
||||
opts.laneId,
|
||||
laneKey,
|
||||
startSeq,
|
||||
new StreamingSha256(),
|
||||
false,
|
||||
0n,
|
||||
);
|
||||
} finally {
|
||||
opts.crypto.zeroize(streamKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt one plaintext chunk. Updates lane sha256 with the plaintext bytes
|
||||
* BEFORE encryption (so receiver can independently verify by hashing
|
||||
* decrypted plaintext in the same order).
|
||||
*/
|
||||
async encryptChunk(plaintext: Uint8Array, isLast: boolean): Promise<EncryptedChunk> {
|
||||
if (this.finished) {
|
||||
throw new StreamFinishedError('StreamSender: lane already finished');
|
||||
}
|
||||
if (this.laneKey === null) {
|
||||
throw new StreamFinishedError('StreamSender: destroyed');
|
||||
}
|
||||
if (this.nextSeq > MAX_SEQ) {
|
||||
throw new ValidationError('seq overflow', 'seq');
|
||||
}
|
||||
|
||||
const seq = this.nextSeq;
|
||||
const nonce = buildChunkNonce(this.laneId, seq);
|
||||
const aad = buildChunkAad(this.streamIdBytes, this.laneId, seq, isLast);
|
||||
const ciphertext = await aesGcmEncryptWithNonce(
|
||||
this.laneKey,
|
||||
nonce,
|
||||
plaintext,
|
||||
aad,
|
||||
this.subtle,
|
||||
);
|
||||
|
||||
this.hasher.update(plaintext);
|
||||
this.bytesSentInternal += BigInt(plaintext.length);
|
||||
this.nextSeq = seq + 1n;
|
||||
|
||||
const envelope: StreamChunkEnvelope = {
|
||||
streamId: this.streamIdBytes,
|
||||
laneId: this.laneId,
|
||||
seq,
|
||||
isLast,
|
||||
nonce,
|
||||
aad: new Uint8Array(0),
|
||||
ciphertext,
|
||||
};
|
||||
const bytes = encodeStreamChunk(envelope);
|
||||
|
||||
if (isLast) this.finished = true;
|
||||
|
||||
return { envelope, bytes, seq: Number(seq) };
|
||||
}
|
||||
|
||||
/** Snapshot the lane sha256 digest. Hasher is frozen after this call. */
|
||||
getLaneSha256Digest(): Uint8Array {
|
||||
return this.hasher.digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed plaintext into the lane sha256 WITHOUT advancing seq or
|
||||
* producing wire bytes. Used by resume flows to re-build the running
|
||||
* lane hash from already-shipped bytes (since `@noble/hashes` v2 doesn't
|
||||
* expose serializable state in v0.2.0).
|
||||
*/
|
||||
preHash(plaintext: Uint8Array): void {
|
||||
if (this.finished) {
|
||||
throw new StreamFinishedError('StreamSender: lane already finished');
|
||||
}
|
||||
this.hasher.update(plaintext);
|
||||
}
|
||||
|
||||
/** Number of plaintext bytes encrypted so far in this lane. */
|
||||
get bytesSent(): bigint {
|
||||
return this.bytesSentInternal;
|
||||
}
|
||||
|
||||
/** Next sequence number this sender will emit. */
|
||||
get nextSequence(): bigint {
|
||||
return this.nextSeq;
|
||||
}
|
||||
|
||||
/** Has this lane emitted its `isLast` chunk? */
|
||||
get isFinished(): boolean {
|
||||
return this.finished;
|
||||
}
|
||||
|
||||
/** Zero the lane key in memory. After destroy, encrypt calls throw. */
|
||||
destroy(): void {
|
||||
if (this.laneKey !== null) {
|
||||
this.crypto.zeroize(this.laneKey);
|
||||
this.laneKey = null;
|
||||
}
|
||||
this.finished = true;
|
||||
}
|
||||
}
|
||||
72
packages/shade-streams/src/types.ts
Normal file
72
packages/shade-streams/src/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Public types for @shade/streams.
|
||||
*
|
||||
* Higher-level wrappers for transfer-orchestration (parallel lanes, resume,
|
||||
* progress, etc.) live in @shade/transfer.
|
||||
*/
|
||||
|
||||
/** Plaintext metadata sent in a stream-init control envelope. */
|
||||
export interface StreamMetadata {
|
||||
name?: string;
|
||||
/** Total plaintext size in bytes. Omit for streams of unknown length. */
|
||||
sizeBytes?: number;
|
||||
contentType?: string;
|
||||
/** Plaintext bytes per chunk for this stream. */
|
||||
chunkSize: number;
|
||||
/** Total chunk count across all lanes. Omit for unknown-length streams. */
|
||||
totalChunks?: number;
|
||||
/** Sender's local clock at init time (advisory; never used for security decisions). */
|
||||
sentAt: number;
|
||||
/**
|
||||
* Optional application-level metadata, JSON-stringified-safe key/value
|
||||
* pairs. Round-tripped verbatim through stream-init plaintext. Used by
|
||||
* higher layers (e.g. `@shade/files` writes a `shadeFilesWriteId` here so
|
||||
* a server-side bridge can correlate an inbound transfer with a pending
|
||||
* RPC). The transport itself does not interpret these values.
|
||||
*/
|
||||
userMetadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Per-lane partition assignment carried in stream-init. */
|
||||
export type LanePartition =
|
||||
| {
|
||||
kind: 'range';
|
||||
/** Inclusive start byte of this lane's slice. */
|
||||
startByte: number;
|
||||
/** Exclusive end byte of this lane's slice. */
|
||||
endByte: number;
|
||||
/** First chunk seq this lane's region begins at (always 0 for per-lane numbering). */
|
||||
startChunk: number;
|
||||
}
|
||||
| {
|
||||
kind: 'round-robin';
|
||||
/** Chunk i goes to lane (i mod count). */
|
||||
lane: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/** Per-lane state included in stream-init plaintext. laneKey is NOT shipped — it is derived. */
|
||||
export interface LaneInitSpec {
|
||||
laneId: number;
|
||||
partition: LanePartition;
|
||||
}
|
||||
|
||||
import type { StreamChunkWire } from '@shade/proto';
|
||||
|
||||
/** Wire-decoded stream-chunk envelope (alias for @shade/proto's `StreamChunkWire`). */
|
||||
export type StreamChunkEnvelope = StreamChunkWire;
|
||||
|
||||
/** Result returned from `StreamSender.encryptChunk`. */
|
||||
export interface EncryptedChunk {
|
||||
envelope: StreamChunkEnvelope;
|
||||
/** Raw bytes ready to ship — output of `encodeStreamChunk`. */
|
||||
bytes: Uint8Array;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
/** Result returned from `StreamReceiver.decryptChunk`. */
|
||||
export interface DecryptedChunk {
|
||||
plaintext: Uint8Array;
|
||||
seq: number;
|
||||
isLast: boolean;
|
||||
}
|
||||
145
packages/shade-streams/tests/aead.test.ts
Normal file
145
packages/shade-streams/tests/aead.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
aesGcmEncryptWithNonce,
|
||||
aesGcmDecryptWithNonce,
|
||||
buildChunkNonce,
|
||||
buildChunkAad,
|
||||
deriveStreamKey,
|
||||
deriveLaneKey,
|
||||
StreamDecryptionError,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
async function laneKey(): Promise<{ key: Uint8Array; streamId: Uint8Array }> {
|
||||
const secret = new Uint8Array(32).fill(0x42);
|
||||
const streamId = new Uint8Array(16).fill(0x99);
|
||||
const sk = await deriveStreamKey(crypto, secret, streamId);
|
||||
const lk = await deriveLaneKey(crypto, sk, streamId, 0);
|
||||
return { key: lk, streamId };
|
||||
}
|
||||
|
||||
describe('aesGcmEncryptWithNonce / aesGcmDecryptWithNonce', () => {
|
||||
test('encrypt → decrypt roundtrip', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const nonce = buildChunkNonce(0, 0);
|
||||
const aad = buildChunkAad(streamId, 0, 0, false);
|
||||
const plaintext = new TextEncoder().encode('hello shade streams');
|
||||
|
||||
const ct = await aesGcmEncryptWithNonce(key, nonce, plaintext, aad);
|
||||
const pt = await aesGcmDecryptWithNonce(key, nonce, ct, aad);
|
||||
expect(new TextDecoder().decode(pt)).toBe('hello shade streams');
|
||||
});
|
||||
|
||||
test('produces ciphertext length = plaintext + 16-byte tag', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const plaintext = new Uint8Array(1024);
|
||||
const ct = await aesGcmEncryptWithNonce(
|
||||
key,
|
||||
buildChunkNonce(0, 0),
|
||||
plaintext,
|
||||
buildChunkAad(streamId, 0, 0, false),
|
||||
);
|
||||
expect(ct.length).toBe(1024 + 16);
|
||||
});
|
||||
|
||||
test('handles empty plaintext', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const nonce = buildChunkNonce(0, 0);
|
||||
const aad = buildChunkAad(streamId, 0, 0, true);
|
||||
const ct = await aesGcmEncryptWithNonce(key, nonce, new Uint8Array(0), aad);
|
||||
expect(ct.length).toBe(16); // tag only
|
||||
const pt = await aesGcmDecryptWithNonce(key, nonce, ct, aad);
|
||||
expect(pt.length).toBe(0);
|
||||
});
|
||||
|
||||
test('handles 1 MiB plaintext (default chunk size)', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const nonce = buildChunkNonce(0, 0);
|
||||
const aad = buildChunkAad(streamId, 0, 0, false);
|
||||
const plaintext = crypto.randomBytes(1024 * 1024);
|
||||
const ct = await aesGcmEncryptWithNonce(key, nonce, plaintext, aad);
|
||||
const pt = await aesGcmDecryptWithNonce(key, nonce, ct, aad);
|
||||
expect(pt).toEqual(plaintext);
|
||||
});
|
||||
|
||||
test('different nonces with same key produce different ciphertexts', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const aad = buildChunkAad(streamId, 0, 0, false);
|
||||
const plaintext = new TextEncoder().encode('same plaintext');
|
||||
const ct1 = await aesGcmEncryptWithNonce(key, buildChunkNonce(0, 0), plaintext, aad);
|
||||
const ct2 = await aesGcmEncryptWithNonce(key, buildChunkNonce(0, 1), plaintext, aad);
|
||||
expect(ct1).not.toEqual(ct2);
|
||||
});
|
||||
|
||||
test('tampered ciphertext byte → StreamDecryptionError', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const nonce = buildChunkNonce(0, 0);
|
||||
const aad = buildChunkAad(streamId, 0, 0, false);
|
||||
const ct = await aesGcmEncryptWithNonce(key, nonce, new TextEncoder().encode('hi'), aad);
|
||||
ct[0] ^= 0x01;
|
||||
await expect(aesGcmDecryptWithNonce(key, nonce, ct, aad)).rejects.toThrow(
|
||||
StreamDecryptionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('tampered tag byte → StreamDecryptionError', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const nonce = buildChunkNonce(0, 0);
|
||||
const aad = buildChunkAad(streamId, 0, 0, false);
|
||||
const ct = await aesGcmEncryptWithNonce(key, nonce, new TextEncoder().encode('hi'), aad);
|
||||
ct[ct.length - 1] ^= 0x80;
|
||||
await expect(aesGcmDecryptWithNonce(key, nonce, ct, aad)).rejects.toThrow(
|
||||
StreamDecryptionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('wrong AAD → StreamDecryptionError', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const nonce = buildChunkNonce(0, 0);
|
||||
const aadEnc = buildChunkAad(streamId, 0, 0, false);
|
||||
const aadDec = buildChunkAad(streamId, 0, 0, true); // isLast flipped
|
||||
const ct = await aesGcmEncryptWithNonce(key, nonce, new TextEncoder().encode('hi'), aadEnc);
|
||||
await expect(aesGcmDecryptWithNonce(key, nonce, ct, aadDec)).rejects.toThrow(
|
||||
StreamDecryptionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('wrong nonce → StreamDecryptionError', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const aad = buildChunkAad(streamId, 0, 0, false);
|
||||
const ct = await aesGcmEncryptWithNonce(
|
||||
key,
|
||||
buildChunkNonce(0, 0),
|
||||
new TextEncoder().encode('hi'),
|
||||
aad,
|
||||
);
|
||||
await expect(
|
||||
aesGcmDecryptWithNonce(key, buildChunkNonce(0, 1), ct, aad),
|
||||
).rejects.toThrow(StreamDecryptionError);
|
||||
});
|
||||
|
||||
test('wrong key → StreamDecryptionError', async () => {
|
||||
const { streamId } = await laneKey();
|
||||
const nonce = buildChunkNonce(0, 0);
|
||||
const aad = buildChunkAad(streamId, 0, 0, false);
|
||||
const k1 = new Uint8Array(32).fill(1);
|
||||
const k2 = new Uint8Array(32).fill(2);
|
||||
const ct = await aesGcmEncryptWithNonce(k1, nonce, new TextEncoder().encode('hi'), aad);
|
||||
await expect(aesGcmDecryptWithNonce(k2, nonce, ct, aad)).rejects.toThrow(
|
||||
StreamDecryptionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-12-byte nonce', async () => {
|
||||
const { key, streamId } = await laneKey();
|
||||
const aad = buildChunkAad(streamId, 0, 0, false);
|
||||
await expect(
|
||||
aesGcmEncryptWithNonce(key, new Uint8Array(11), new Uint8Array(0), aad),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
aesGcmDecryptWithNonce(key, new Uint8Array(13), new Uint8Array(16), aad),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
281
packages/shade-streams/tests/coordinator.test.ts
Normal file
281
packages/shade-streams/tests/coordinator.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import {
|
||||
MultiLaneSender,
|
||||
MultiLaneReceiver,
|
||||
StreamProtocolError,
|
||||
generateStreamId,
|
||||
generateStreamSecret,
|
||||
planRangePartition,
|
||||
planRoundRobinPartition,
|
||||
chunkRange,
|
||||
sha256Once,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
function hex(b: Uint8Array): string {
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Roundtrip a fixed input through `laneCount` lanes using range partitioning.
|
||||
* Returns the per-side overall sha256 + the reconstructed plaintext.
|
||||
*/
|
||||
async function roundtripRange(input: Uint8Array, laneCount: number, chunkSize: number) {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const streamSecret = generateStreamSecret(crypto);
|
||||
const lanes = planRangePartition(input.length, laneCount);
|
||||
|
||||
const sender = await MultiLaneSender.create({ crypto, streamId, streamSecret, lanes });
|
||||
const receiver = await MultiLaneReceiver.create({ crypto, streamId, streamSecret, lanes });
|
||||
|
||||
// Append the entire input to the sender's overall hasher in original order
|
||||
// (range mode: lane i's slice is contiguous in original order).
|
||||
sender.appendOverall(input);
|
||||
|
||||
// Encrypt all chunks for all lanes (interleaved as a real consumer would).
|
||||
const wireChunks: Array<{ laneId: number; bytes: Uint8Array }> = [];
|
||||
for (const lane of lanes) {
|
||||
if (lane.partition.kind !== 'range') throw new Error('expected range');
|
||||
const slices = chunkRange(lane.partition.startByte, lane.partition.endByte, chunkSize);
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const s = slices[i]!;
|
||||
const isLast = i === slices.length - 1;
|
||||
const plaintext = input.subarray(s.start, s.end);
|
||||
const { bytes } = await sender.encryptForLane(lane.laneId, plaintext, isLast);
|
||||
wireChunks.push({ laneId: lane.laneId, bytes });
|
||||
}
|
||||
}
|
||||
|
||||
// Receiver decrypts. Range mode: gather lane outputs in laneId order.
|
||||
const laneBuffers = new Map<number, Uint8Array[]>();
|
||||
for (const { bytes } of wireChunks) {
|
||||
const dec = await receiver.decryptChunk(bytes);
|
||||
if (!laneBuffers.has(dec.laneId)) laneBuffers.set(dec.laneId, []);
|
||||
laneBuffers.get(dec.laneId)!.push(dec.plaintext);
|
||||
}
|
||||
// Concatenate lane outputs in laneId order to rebuild original byte order.
|
||||
const reconstructed: Uint8Array[] = [];
|
||||
for (let i = 0; i < laneCount; i++) {
|
||||
for (const piece of laneBuffers.get(i) ?? []) reconstructed.push(piece);
|
||||
}
|
||||
// Feed receiver's overall hasher in original byte order.
|
||||
for (const piece of reconstructed) receiver.appendOverall(piece);
|
||||
|
||||
return {
|
||||
sender,
|
||||
receiver,
|
||||
senderOverall: sender.getOverallSha256(),
|
||||
receiverOverall: receiver.getOverallSha256(),
|
||||
reconstructed: concat(reconstructed),
|
||||
};
|
||||
}
|
||||
|
||||
/** Roundtrip via round-robin partitioning. Chunk i goes to lane (i mod L). */
|
||||
async function roundtripRoundRobin(
|
||||
input: Uint8Array,
|
||||
laneCount: number,
|
||||
chunkSize: number,
|
||||
) {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const streamSecret = generateStreamSecret(crypto);
|
||||
const lanes = planRoundRobinPartition(laneCount);
|
||||
|
||||
const sender = await MultiLaneSender.create({ crypto, streamId, streamSecret, lanes });
|
||||
const receiver = await MultiLaneReceiver.create({ crypto, streamId, streamSecret, lanes });
|
||||
|
||||
// Append in original order.
|
||||
sender.appendOverall(input);
|
||||
|
||||
// Slice into chunks; round-robin assignment.
|
||||
const slices = chunkRange(0, input.length, chunkSize);
|
||||
// Determine `isLast` for each lane (last chunk this lane sees).
|
||||
const lastChunkByLane = new Map<number, number>();
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
lastChunkByLane.set(i % laneCount, i);
|
||||
}
|
||||
const wireChunks: Array<{ chunkIndex: number; bytes: Uint8Array }> = [];
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const s = slices[i]!;
|
||||
const laneId = i % laneCount;
|
||||
const isLast = lastChunkByLane.get(laneId) === i;
|
||||
const plaintext = input.subarray(s.start, s.end);
|
||||
const { bytes } = await sender.encryptForLane(laneId, plaintext, isLast);
|
||||
wireChunks.push({ chunkIndex: i, bytes });
|
||||
}
|
||||
|
||||
// Receiver: collect chunks; reorder by chunkIndex (the original-order index).
|
||||
const decoded = new Map<number, Uint8Array>();
|
||||
for (const { chunkIndex, bytes } of wireChunks) {
|
||||
const dec = await receiver.decryptChunk(bytes);
|
||||
decoded.set(chunkIndex, dec.plaintext);
|
||||
}
|
||||
const reconstructed: Uint8Array[] = [];
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
reconstructed.push(decoded.get(i)!);
|
||||
}
|
||||
for (const piece of reconstructed) receiver.appendOverall(piece);
|
||||
|
||||
return {
|
||||
sender,
|
||||
receiver,
|
||||
senderOverall: sender.getOverallSha256(),
|
||||
receiverOverall: receiver.getOverallSha256(),
|
||||
reconstructed: concat(reconstructed),
|
||||
};
|
||||
}
|
||||
|
||||
function concat(parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((s, p) => s + p.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, off);
|
||||
off += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('MultiLaneSender / MultiLaneReceiver — basic shape', () => {
|
||||
test('rejects empty lanes array', async () => {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const streamSecret = generateStreamSecret(crypto);
|
||||
await expect(
|
||||
MultiLaneSender.create({ crypto, streamId, streamSecret, lanes: [] }),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('rejects duplicate laneIds', async () => {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const streamSecret = generateStreamSecret(crypto);
|
||||
await expect(
|
||||
MultiLaneSender.create({
|
||||
crypto,
|
||||
streamId,
|
||||
streamSecret,
|
||||
lanes: [
|
||||
{ laneId: 0, partition: { kind: 'round-robin', lane: 0, count: 2 } },
|
||||
{ laneId: 0, partition: { kind: 'round-robin', lane: 1, count: 2 } },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('encryptForLane on unknown laneId throws StreamProtocolError', async () => {
|
||||
const sender = await MultiLaneSender.create({
|
||||
crypto,
|
||||
streamId: generateStreamId(crypto),
|
||||
streamSecret: generateStreamSecret(crypto),
|
||||
lanes: planRoundRobinPartition(2),
|
||||
});
|
||||
await expect(sender.encryptForLane(99, new Uint8Array(0), false)).rejects.toThrow(
|
||||
StreamProtocolError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Range-partition roundtrip', () => {
|
||||
test('1 KB / 4 lanes / 256 B chunk', async () => {
|
||||
const input = crypto.randomBytes(1024);
|
||||
const r = await roundtripRange(input, 4, 256);
|
||||
expect(r.reconstructed).toEqual(input);
|
||||
expect(hex(r.senderOverall)).toBe(hex(r.receiverOverall));
|
||||
expect(hex(r.senderOverall)).toBe(hex(sha256Once(input)));
|
||||
});
|
||||
|
||||
test('exactly chunkSize-aligned input', async () => {
|
||||
const input = crypto.randomBytes(8 * 256);
|
||||
const r = await roundtripRange(input, 4, 256);
|
||||
expect(r.reconstructed).toEqual(input);
|
||||
expect(hex(r.senderOverall)).toBe(hex(r.receiverOverall));
|
||||
});
|
||||
|
||||
test('input smaller than chunkSize × laneCount', async () => {
|
||||
const input = crypto.randomBytes(50);
|
||||
const r = await roundtripRange(input, 4, 64);
|
||||
expect(r.reconstructed).toEqual(input);
|
||||
expect(hex(r.senderOverall)).toBe(hex(r.receiverOverall));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Round-robin partition roundtrip', () => {
|
||||
test('4 lanes, 1 KB / 128 B chunks', async () => {
|
||||
const input = crypto.randomBytes(1024);
|
||||
const r = await roundtripRoundRobin(input, 4, 128);
|
||||
expect(r.reconstructed).toEqual(input);
|
||||
expect(hex(r.senderOverall)).toBe(hex(r.receiverOverall));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lane-parity ship-gate (1 / 4 / 16 lanes → same overallSha256)', () => {
|
||||
const sizes = [
|
||||
{ label: '1 KiB', bytes: 1024 },
|
||||
{ label: '256 KiB', bytes: 256 * 1024 },
|
||||
{ label: '2 MiB', bytes: 2 * 1024 * 1024 },
|
||||
];
|
||||
|
||||
for (const { label, bytes } of sizes) {
|
||||
test(`${label} input — same sha256 across {1, 4, 16} lanes (range)`, async () => {
|
||||
const input = crypto.randomBytes(bytes);
|
||||
const expected = hex(sha256Once(input));
|
||||
for (const laneCount of [1, 4, 16]) {
|
||||
const r = await roundtripRange(input, laneCount, 64 * 1024);
|
||||
expect(r.reconstructed).toEqual(input);
|
||||
expect(hex(r.senderOverall)).toBe(expected);
|
||||
expect(hex(r.receiverOverall)).toBe(expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('1 MiB input — same sha256 across {1, 4, 16} lanes (round-robin)', async () => {
|
||||
const input = crypto.randomBytes(1024 * 1024);
|
||||
const expected = hex(sha256Once(input));
|
||||
for (const laneCount of [1, 4, 16]) {
|
||||
const r = await roundtripRoundRobin(input, laneCount, 32 * 1024);
|
||||
expect(r.reconstructed).toEqual(input);
|
||||
expect(hex(r.senderOverall)).toBe(expected);
|
||||
expect(hex(r.receiverOverall)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('range and round-robin produce the same overall sha256 for the same input', async () => {
|
||||
const input = crypto.randomBytes(128 * 1024);
|
||||
const a = await roundtripRange(input, 4, 16 * 1024);
|
||||
const b = await roundtripRoundRobin(input, 4, 16 * 1024);
|
||||
expect(hex(a.senderOverall)).toBe(hex(b.senderOverall));
|
||||
expect(hex(a.receiverOverall)).toBe(hex(b.receiverOverall));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Per-lane fingerprints', () => {
|
||||
test('match between sender and receiver after roundtrip', async () => {
|
||||
const input = crypto.randomBytes(64 * 1024);
|
||||
const r = await roundtripRange(input, 4, 8 * 1024);
|
||||
const senderFps = r.sender.getLaneFingerprints();
|
||||
const receiverFps = r.receiver.getLaneFingerprints();
|
||||
expect(senderFps.length).toBe(4);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(hex(senderFps[i]!.sha256)).toBe(hex(receiverFps[i]!.sha256));
|
||||
expect(senderFps[i]!.byteCount).toBe(receiverFps[i]!.byteCount);
|
||||
expect(senderFps[i]!.chunkCount).toBe(receiverFps[i]!.chunkCount);
|
||||
}
|
||||
});
|
||||
|
||||
test('byteCount across all lanes equals total input', async () => {
|
||||
const input = crypto.randomBytes(99 * 1024); // intentionally non-divisible
|
||||
const r = await roundtripRange(input, 4, 8 * 1024);
|
||||
const total = r.sender
|
||||
.getLaneFingerprints()
|
||||
.reduce((s, l) => s + l.byteCount, 0);
|
||||
expect(total).toBe(input.length);
|
||||
});
|
||||
|
||||
test('allLanesFinished reflects per-lane completion', async () => {
|
||||
const input = crypto.randomBytes(1024);
|
||||
const r = await roundtripRange(input, 2, 256);
|
||||
expect(r.sender.allLanesFinished).toBe(true);
|
||||
expect(r.receiver.allLanesFinished).toBe(true);
|
||||
});
|
||||
});
|
||||
92
packages/shade-streams/tests/envelope.test.ts
Normal file
92
packages/shade-streams/tests/envelope.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import {
|
||||
encodeStreamControl,
|
||||
parseStreamControl,
|
||||
isStreamControlMessage,
|
||||
} from '../src/index.js';
|
||||
import type {
|
||||
StreamInitMessage,
|
||||
StreamFinishMessage,
|
||||
StreamAbortMessage,
|
||||
} from '../src/index.js';
|
||||
|
||||
describe('control envelope encode/parse roundtrip', () => {
|
||||
test('stream-init', () => {
|
||||
const msg: StreamInitMessage = {
|
||||
kind: 'shade.stream-init/v1',
|
||||
streamId: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||
streamSecret: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA',
|
||||
metadata: {
|
||||
name: 'world.zip',
|
||||
sizeBytes: 1024,
|
||||
contentType: 'application/zip',
|
||||
chunkSize: 256,
|
||||
totalChunks: 4,
|
||||
sentAt: 1730000000000,
|
||||
},
|
||||
lanes: [
|
||||
{
|
||||
laneId: 0,
|
||||
partition: { kind: 'range', startByte: 0, endByte: 512, startChunk: 0 },
|
||||
},
|
||||
{
|
||||
laneId: 1,
|
||||
partition: { kind: 'range', startByte: 512, endByte: 1024, startChunk: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const json = encodeStreamControl(msg);
|
||||
expect(parseStreamControl(json)).toEqual(msg);
|
||||
});
|
||||
|
||||
test('stream-finish', () => {
|
||||
const msg: StreamFinishMessage = {
|
||||
kind: 'shade.stream-finish/v1',
|
||||
streamId: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||
laneSha256: [{ laneId: 0, sha256: 'abcd', chunkCount: 1, byteCount: 256 }],
|
||||
overallSha256: 'efgh',
|
||||
finishedAt: 1730000001000,
|
||||
};
|
||||
expect(parseStreamControl(encodeStreamControl(msg))).toEqual(msg);
|
||||
});
|
||||
|
||||
test('stream-abort', () => {
|
||||
const msg: StreamAbortMessage = {
|
||||
kind: 'shade.stream-abort/v1',
|
||||
streamId: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||
reason: 'sender-cancel',
|
||||
message: 'user clicked cancel',
|
||||
abortedAt: 1730000002000,
|
||||
};
|
||||
expect(parseStreamControl(encodeStreamControl(msg))).toEqual(msg);
|
||||
});
|
||||
|
||||
test('rejects malformed JSON', () => {
|
||||
expect(() => parseStreamControl('not-json')).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('rejects messages without a kind field', () => {
|
||||
expect(() => parseStreamControl(JSON.stringify({ foo: 'bar' }))).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('rejects messages whose kind does not start with shade.stream-', () => {
|
||||
expect(() => parseStreamControl(JSON.stringify({ kind: 'other.kind' }))).toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStreamControlMessage', () => {
|
||||
test('returns true for valid shapes', () => {
|
||||
expect(isStreamControlMessage({ kind: 'shade.stream-init/v1' })).toBe(true);
|
||||
expect(isStreamControlMessage({ kind: 'shade.stream-finish/v1' })).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for non-objects and missing kind', () => {
|
||||
expect(isStreamControlMessage(null)).toBe(false);
|
||||
expect(isStreamControlMessage(42)).toBe(false);
|
||||
expect(isStreamControlMessage({})).toBe(false);
|
||||
expect(isStreamControlMessage({ kind: 'unrelated' })).toBe(false);
|
||||
});
|
||||
});
|
||||
72
packages/shade-streams/tests/hash.test.ts
Normal file
72
packages/shade-streams/tests/hash.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { StreamingSha256, sha256Once } from '../src/index.js';
|
||||
|
||||
function hex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function subtleHashHex(data: Uint8Array): Promise<string> {
|
||||
const buf = await globalThis.crypto.subtle.digest('SHA-256', data as unknown as ArrayBuffer);
|
||||
return hex(new Uint8Array(buf));
|
||||
}
|
||||
|
||||
describe('StreamingSha256', () => {
|
||||
test('digest of empty input matches the well-known SHA-256 zero hash', () => {
|
||||
const h = new StreamingSha256().digest();
|
||||
expect(hex(h)).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||
});
|
||||
|
||||
test('matches one-shot sha256 over the same bytes', () => {
|
||||
const data = new TextEncoder().encode('the quick brown fox');
|
||||
const streaming = new StreamingSha256().update(data).digest();
|
||||
expect(hex(streaming)).toBe(hex(sha256Once(data)));
|
||||
});
|
||||
|
||||
test('matches SubtleCrypto digest over the same bytes', async () => {
|
||||
const data = new TextEncoder().encode('cross-impl parity check');
|
||||
const streaming = new StreamingSha256().update(data).digest();
|
||||
expect(hex(streaming)).toBe(await subtleHashHex(data));
|
||||
});
|
||||
|
||||
test('chunked updates produce identical digest to a single update', () => {
|
||||
const buf = new Uint8Array(4096);
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = i & 0xff;
|
||||
const a = new StreamingSha256().update(buf).digest();
|
||||
const b = new StreamingSha256();
|
||||
for (let off = 0; off < buf.length; off += 137) {
|
||||
b.update(buf.slice(off, Math.min(off + 137, buf.length)));
|
||||
}
|
||||
expect(hex(a)).toBe(hex(b.digest()));
|
||||
});
|
||||
|
||||
test('handles multi-megabyte inputs (memory-bounded streaming)', () => {
|
||||
const chunk = new Uint8Array(1024 * 1024);
|
||||
for (let i = 0; i < chunk.length; i++) chunk[i] = (i * 31) & 0xff;
|
||||
const h = new StreamingSha256();
|
||||
for (let i = 0; i < 4; i++) h.update(chunk);
|
||||
const digest = h.digest();
|
||||
expect(digest.length).toBe(32);
|
||||
});
|
||||
|
||||
test('throws on update after digest()', () => {
|
||||
const h = new StreamingSha256();
|
||||
h.digest();
|
||||
expect(() => h.update(new Uint8Array([1]))).toThrow();
|
||||
});
|
||||
|
||||
test('isFinalized reflects digest()', () => {
|
||||
const h = new StreamingSha256();
|
||||
expect(h.isFinalized).toBe(false);
|
||||
h.digest();
|
||||
expect(h.isFinalized).toBe(true);
|
||||
});
|
||||
|
||||
test('skips no-op empty updates', () => {
|
||||
const h = new StreamingSha256();
|
||||
h.update(new Uint8Array(0));
|
||||
h.update(new Uint8Array(0));
|
||||
expect(hex(h.digest())).toBe(
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
);
|
||||
});
|
||||
});
|
||||
55
packages/shade-streams/tests/ids.test.ts
Normal file
55
packages/shade-streams/tests/ids.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import {
|
||||
generateStreamId,
|
||||
generateStreamSecret,
|
||||
streamIdToString,
|
||||
streamIdFromString,
|
||||
STREAM_ID_BYTES,
|
||||
STREAM_SECRET_BYTES,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
describe('streamId / streamSecret generators', () => {
|
||||
test('streamId is 16 bytes', () => {
|
||||
expect(generateStreamId(crypto).length).toBe(STREAM_ID_BYTES);
|
||||
});
|
||||
|
||||
test('streamSecret is 32 bytes', () => {
|
||||
expect(generateStreamSecret(crypto).length).toBe(STREAM_SECRET_BYTES);
|
||||
});
|
||||
|
||||
test('successive generations are not equal (high-entropy)', () => {
|
||||
const a = generateStreamId(crypto);
|
||||
const b = generateStreamId(crypto);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64url encode/decode roundtrip', () => {
|
||||
test('roundtrips arbitrary 16-byte streamIds', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const id = generateStreamId(crypto);
|
||||
const s = streamIdToString(id);
|
||||
expect(streamIdFromString(s)).toEqual(id);
|
||||
}
|
||||
});
|
||||
|
||||
test('emits URL-safe alphabet (no +, /, =)', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const s = streamIdToString(generateStreamId(crypto));
|
||||
expect(s).not.toMatch(/[+/=]/);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects wrong-length streamId on encode', () => {
|
||||
expect(() => streamIdToString(new Uint8Array(15))).toThrow(ValidationError);
|
||||
expect(() => streamIdToString(new Uint8Array(17))).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('rejects strings that decode to wrong length', () => {
|
||||
expect(() => streamIdFromString('AAAA')).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
110
packages/shade-streams/tests/kdf.test.ts
Normal file
110
packages/shade-streams/tests/kdf.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import {
|
||||
deriveStreamKey,
|
||||
deriveLaneKey,
|
||||
generateStreamId,
|
||||
generateStreamSecret,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
function hex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
describe('deriveStreamKey', () => {
|
||||
test('produces 32-byte output', async () => {
|
||||
const secret = generateStreamSecret(crypto);
|
||||
const id = generateStreamId(crypto);
|
||||
const key = await deriveStreamKey(crypto, secret, id);
|
||||
expect(key.length).toBe(32);
|
||||
});
|
||||
|
||||
test('is deterministic for the same inputs', async () => {
|
||||
const secret = new Uint8Array(32).fill(7);
|
||||
const id = new Uint8Array(16).fill(3);
|
||||
const a = await deriveStreamKey(crypto, secret, id);
|
||||
const b = await deriveStreamKey(crypto, secret, id);
|
||||
expect(hex(a)).toBe(hex(b));
|
||||
});
|
||||
|
||||
test('changes with streamSecret', async () => {
|
||||
const id = new Uint8Array(16).fill(1);
|
||||
const a = await deriveStreamKey(crypto, new Uint8Array(32).fill(1), id);
|
||||
const b = await deriveStreamKey(crypto, new Uint8Array(32).fill(2), id);
|
||||
expect(hex(a)).not.toBe(hex(b));
|
||||
});
|
||||
|
||||
test('changes with streamId', async () => {
|
||||
const secret = new Uint8Array(32).fill(9);
|
||||
const a = await deriveStreamKey(crypto, secret, new Uint8Array(16).fill(1));
|
||||
const b = await deriveStreamKey(crypto, secret, new Uint8Array(16).fill(2));
|
||||
expect(hex(a)).not.toBe(hex(b));
|
||||
});
|
||||
|
||||
test('rejects wrong-length streamSecret', async () => {
|
||||
const id = new Uint8Array(16);
|
||||
await expect(deriveStreamKey(crypto, new Uint8Array(31), id)).rejects.toThrow(ValidationError);
|
||||
await expect(deriveStreamKey(crypto, new Uint8Array(33), id)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('rejects wrong-length streamId', async () => {
|
||||
const secret = new Uint8Array(32);
|
||||
await expect(deriveStreamKey(crypto, secret, new Uint8Array(15))).rejects.toThrow(ValidationError);
|
||||
await expect(deriveStreamKey(crypto, secret, new Uint8Array(17))).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveLaneKey', () => {
|
||||
test('produces 32-byte output', async () => {
|
||||
const streamKey = new Uint8Array(32).fill(5);
|
||||
const id = new Uint8Array(16).fill(2);
|
||||
const laneKey = await deriveLaneKey(crypto, streamKey, id, 0);
|
||||
expect(laneKey.length).toBe(32);
|
||||
});
|
||||
|
||||
test('is deterministic for the same (streamKey, streamId, laneId)', async () => {
|
||||
const streamKey = new Uint8Array(32).fill(5);
|
||||
const id = new Uint8Array(16).fill(2);
|
||||
const a = await deriveLaneKey(crypto, streamKey, id, 7);
|
||||
const b = await deriveLaneKey(crypto, streamKey, id, 7);
|
||||
expect(hex(a)).toBe(hex(b));
|
||||
});
|
||||
|
||||
test('different laneId yields different lane keys', async () => {
|
||||
const streamKey = new Uint8Array(32).fill(5);
|
||||
const id = new Uint8Array(16).fill(2);
|
||||
const a = await deriveLaneKey(crypto, streamKey, id, 0);
|
||||
const b = await deriveLaneKey(crypto, streamKey, id, 1);
|
||||
expect(hex(a)).not.toBe(hex(b));
|
||||
});
|
||||
|
||||
test('different streamKey yields different lane keys', async () => {
|
||||
const id = new Uint8Array(16).fill(2);
|
||||
const a = await deriveLaneKey(crypto, new Uint8Array(32).fill(5), id, 0);
|
||||
const b = await deriveLaneKey(crypto, new Uint8Array(32).fill(6), id, 0);
|
||||
expect(hex(a)).not.toBe(hex(b));
|
||||
});
|
||||
|
||||
test('rejects laneId outside u32 range', async () => {
|
||||
const streamKey = new Uint8Array(32);
|
||||
const id = new Uint8Array(16);
|
||||
await expect(deriveLaneKey(crypto, streamKey, id, -1)).rejects.toThrow(ValidationError);
|
||||
await expect(deriveLaneKey(crypto, streamKey, id, 0x1_0000_0000)).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
await expect(deriveLaneKey(crypto, streamKey, id, 1.5)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('full pipeline: streamSecret → streamKey → laneKey is deterministic across both sides', async () => {
|
||||
const secret = new Uint8Array(32).fill(0xab);
|
||||
const id = new Uint8Array(16).fill(0xcd);
|
||||
const senderStreamKey = await deriveStreamKey(crypto, secret, id);
|
||||
const senderLaneKey = await deriveLaneKey(crypto, senderStreamKey, id, 3);
|
||||
const receiverStreamKey = await deriveStreamKey(crypto, secret, id);
|
||||
const receiverLaneKey = await deriveLaneKey(crypto, receiverStreamKey, id, 3);
|
||||
expect(hex(senderLaneKey)).toBe(hex(receiverLaneKey));
|
||||
});
|
||||
});
|
||||
100
packages/shade-streams/tests/nonce.test.ts
Normal file
100
packages/shade-streams/tests/nonce.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import { buildChunkNonce, buildChunkAad, MAX_SEQ, STREAM_NONCE_BYTES } from '../src/index.js';
|
||||
|
||||
describe('buildChunkNonce', () => {
|
||||
test('produces 12-byte output', () => {
|
||||
expect(buildChunkNonce(0, 0).length).toBe(STREAM_NONCE_BYTES);
|
||||
});
|
||||
|
||||
test('encodes laneId as u32 BE in bytes [0..4)', () => {
|
||||
const n = buildChunkNonce(0x01020304, 0);
|
||||
expect(n[0]).toBe(0x01);
|
||||
expect(n[1]).toBe(0x02);
|
||||
expect(n[2]).toBe(0x03);
|
||||
expect(n[3]).toBe(0x04);
|
||||
});
|
||||
|
||||
test('encodes seq as u64 BE in bytes [4..12)', () => {
|
||||
const n = buildChunkNonce(0, 0x0102030405060708n);
|
||||
expect(n[4]).toBe(0x01);
|
||||
expect(n[5]).toBe(0x02);
|
||||
expect(n[6]).toBe(0x03);
|
||||
expect(n[7]).toBe(0x04);
|
||||
expect(n[8]).toBe(0x05);
|
||||
expect(n[9]).toBe(0x06);
|
||||
expect(n[10]).toBe(0x07);
|
||||
expect(n[11]).toBe(0x08);
|
||||
});
|
||||
|
||||
test('different (laneId, seq) yields different nonces', () => {
|
||||
const seen = new Set<string>();
|
||||
for (let lane = 0; lane < 4; lane++) {
|
||||
for (let seq = 0; seq < 100; seq++) {
|
||||
const n = buildChunkNonce(lane, seq);
|
||||
const key = Array.from(n).join(',');
|
||||
expect(seen.has(key)).toBe(false);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts both number and bigint seq', () => {
|
||||
const a = buildChunkNonce(1, 42);
|
||||
const b = buildChunkNonce(1, 42n);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test('handles MAX_SEQ', () => {
|
||||
const n = buildChunkNonce(0, MAX_SEQ);
|
||||
for (let i = 4; i < 12; i++) expect(n[i]).toBe(0xff);
|
||||
});
|
||||
|
||||
test('rejects out-of-range laneId', () => {
|
||||
expect(() => buildChunkNonce(-1, 0)).toThrow(ValidationError);
|
||||
expect(() => buildChunkNonce(0x1_0000_0000, 0)).toThrow(ValidationError);
|
||||
expect(() => buildChunkNonce(1.5, 0)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('rejects out-of-range seq', () => {
|
||||
expect(() => buildChunkNonce(0, -1)).toThrow(ValidationError);
|
||||
expect(() => buildChunkNonce(0, MAX_SEQ + 1n)).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChunkAad', () => {
|
||||
test('produces 29-byte output (16+4+8+1)', () => {
|
||||
const id = new Uint8Array(16);
|
||||
expect(buildChunkAad(id, 0, 0, false).length).toBe(29);
|
||||
});
|
||||
|
||||
test('embeds streamId, laneId, seq, isLast in canonical layout', () => {
|
||||
const id = new Uint8Array(16).fill(0xaa);
|
||||
const aad = buildChunkAad(id, 0x01020304, 0x05060708090a0b0cn, true);
|
||||
for (let i = 0; i < 16; i++) expect(aad[i]).toBe(0xaa);
|
||||
expect(aad[16]).toBe(0x01);
|
||||
expect(aad[17]).toBe(0x02);
|
||||
expect(aad[18]).toBe(0x03);
|
||||
expect(aad[19]).toBe(0x04);
|
||||
expect(aad[20]).toBe(0x05);
|
||||
expect(aad[27]).toBe(0x0c);
|
||||
expect(aad[28]).toBe(0x01);
|
||||
});
|
||||
|
||||
test('isLast=false sets last byte to 0x00', () => {
|
||||
const id = new Uint8Array(16);
|
||||
const aad = buildChunkAad(id, 0, 0, false);
|
||||
expect(aad[28]).toBe(0x00);
|
||||
});
|
||||
|
||||
test('rejects wrong-length streamId', () => {
|
||||
expect(() => buildChunkAad(new Uint8Array(15), 0, 0, false)).toThrow(ValidationError);
|
||||
expect(() => buildChunkAad(new Uint8Array(17), 0, 0, false)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('rejects out-of-range laneId / seq', () => {
|
||||
const id = new Uint8Array(16);
|
||||
expect(() => buildChunkAad(id, -1, 0, false)).toThrow(ValidationError);
|
||||
expect(() => buildChunkAad(id, 0, -1, false)).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
159
packages/shade-streams/tests/partition.test.ts
Normal file
159
packages/shade-streams/tests/partition.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { ValidationError } from '@shade/core';
|
||||
import {
|
||||
planRangePartition,
|
||||
planRoundRobinPartition,
|
||||
chunkRange,
|
||||
partitionsEqual,
|
||||
} from '../src/index.js';
|
||||
|
||||
describe('planRangePartition', () => {
|
||||
test('evenly divisible totalBytes', () => {
|
||||
const lanes = planRangePartition(100, 4);
|
||||
expect(lanes).toHaveLength(4);
|
||||
expect(lanes[0]!.partition).toEqual({ kind: 'range', startByte: 0, endByte: 25, startChunk: 0 });
|
||||
expect(lanes[3]!.partition).toEqual({ kind: 'range', startByte: 75, endByte: 100, startChunk: 0 });
|
||||
});
|
||||
|
||||
test('non-divisible: extra bytes go to early lanes', () => {
|
||||
const lanes = planRangePartition(10, 3);
|
||||
const ranges = lanes.map((l) => l.partition);
|
||||
// 10 / 3 = 3 remainder 1 — lane 0 gets 4, lanes 1+2 get 3 each
|
||||
expect(ranges[0]).toEqual({ kind: 'range', startByte: 0, endByte: 4, startChunk: 0 });
|
||||
expect(ranges[1]).toEqual({ kind: 'range', startByte: 4, endByte: 7, startChunk: 0 });
|
||||
expect(ranges[2]).toEqual({ kind: 'range', startByte: 7, endByte: 10, startChunk: 0 });
|
||||
});
|
||||
|
||||
test('lanes cover entire range without gaps or overlap', () => {
|
||||
for (const total of [0, 1, 7, 100, 1024 * 1024]) {
|
||||
for (const count of [1, 2, 4, 16]) {
|
||||
const lanes = planRangePartition(total, count);
|
||||
let cursor = 0;
|
||||
for (const lane of lanes) {
|
||||
if (lane.partition.kind !== 'range') throw new Error('expected range');
|
||||
expect(lane.partition.startByte).toBe(cursor);
|
||||
cursor = lane.partition.endByte;
|
||||
}
|
||||
expect(cursor).toBe(total);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('1-lane partition spans entire input', () => {
|
||||
const lanes = planRangePartition(500, 1);
|
||||
expect(lanes).toHaveLength(1);
|
||||
expect(lanes[0]!.partition).toEqual({ kind: 'range', startByte: 0, endByte: 500, startChunk: 0 });
|
||||
});
|
||||
|
||||
test('rejects negative totalBytes / fractional / non-positive count', () => {
|
||||
expect(() => planRangePartition(-1, 1)).toThrow(ValidationError);
|
||||
expect(() => planRangePartition(1.5, 1)).toThrow(ValidationError);
|
||||
expect(() => planRangePartition(100, 0)).toThrow(ValidationError);
|
||||
expect(() => planRangePartition(100, -1)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test('laneIds are 0..count-1 in order', () => {
|
||||
const lanes = planRangePartition(64, 16);
|
||||
for (let i = 0; i < 16; i++) expect(lanes[i]!.laneId).toBe(i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planRoundRobinPartition', () => {
|
||||
test('produces N lanes labeled 0..N-1', () => {
|
||||
const lanes = planRoundRobinPartition(8);
|
||||
expect(lanes).toHaveLength(8);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
expect(lanes[i]!.laneId).toBe(i);
|
||||
expect(lanes[i]!.partition).toEqual({ kind: 'round-robin', lane: i, count: 8 });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-positive count', () => {
|
||||
expect(() => planRoundRobinPartition(0)).toThrow(ValidationError);
|
||||
expect(() => planRoundRobinPartition(-1)).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chunkRange', () => {
|
||||
test('splits an even range into chunkSize slices', () => {
|
||||
expect(chunkRange(0, 1024, 256)).toEqual([
|
||||
{ start: 0, end: 256 },
|
||||
{ start: 256, end: 512 },
|
||||
{ start: 512, end: 768 },
|
||||
{ start: 768, end: 1024 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('last chunk truncated for non-divisible range', () => {
|
||||
expect(chunkRange(0, 1000, 256)).toEqual([
|
||||
{ start: 0, end: 256 },
|
||||
{ start: 256, end: 512 },
|
||||
{ start: 512, end: 768 },
|
||||
{ start: 768, end: 1000 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('non-zero start offset is preserved', () => {
|
||||
expect(chunkRange(100, 350, 100)).toEqual([
|
||||
{ start: 100, end: 200 },
|
||||
{ start: 200, end: 300 },
|
||||
{ start: 300, end: 350 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('empty range produces a single empty chunk (so isLast can be carried)', () => {
|
||||
expect(chunkRange(50, 50, 100)).toEqual([{ start: 50, end: 50 }]);
|
||||
});
|
||||
|
||||
test('rejects non-positive chunkSize', () => {
|
||||
expect(() => chunkRange(0, 100, 0)).toThrow(ValidationError);
|
||||
expect(() => chunkRange(0, 100, -1)).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('partitionsEqual', () => {
|
||||
test('identical range partitions', () => {
|
||||
expect(
|
||||
partitionsEqual(
|
||||
{ kind: 'range', startByte: 0, endByte: 100, startChunk: 0 },
|
||||
{ kind: 'range', startByte: 0, endByte: 100, startChunk: 0 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('different range bounds', () => {
|
||||
expect(
|
||||
partitionsEqual(
|
||||
{ kind: 'range', startByte: 0, endByte: 100, startChunk: 0 },
|
||||
{ kind: 'range', startByte: 0, endByte: 200, startChunk: 0 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('range vs round-robin → false', () => {
|
||||
expect(
|
||||
partitionsEqual(
|
||||
{ kind: 'range', startByte: 0, endByte: 100, startChunk: 0 },
|
||||
{ kind: 'round-robin', lane: 0, count: 1 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('identical round-robin partitions', () => {
|
||||
expect(
|
||||
partitionsEqual(
|
||||
{ kind: 'round-robin', lane: 2, count: 4 },
|
||||
{ kind: 'round-robin', lane: 2, count: 4 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('different round-robin lane index', () => {
|
||||
expect(
|
||||
partitionsEqual(
|
||||
{ kind: 'round-robin', lane: 1, count: 4 },
|
||||
{ kind: 'round-robin', lane: 2, count: 4 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
71
packages/shade-streams/tests/replay.test.ts
Normal file
71
packages/shade-streams/tests/replay.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
StreamSender,
|
||||
StreamReceiver,
|
||||
StreamReplayError,
|
||||
StreamOutOfOrderError,
|
||||
generateStreamId,
|
||||
generateStreamSecret,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
async function pair() {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const streamSecret = generateStreamSecret(crypto);
|
||||
const sender = await StreamSender.create({ crypto, streamId, streamSecret, laneId: 0 });
|
||||
const receiver = await StreamReceiver.create({ crypto, streamId, streamSecret, laneId: 0 });
|
||||
return { sender, receiver };
|
||||
}
|
||||
|
||||
describe('Replay and out-of-order detection', () => {
|
||||
test('replaying the same chunk twice → StreamReplayError', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('first'), false);
|
||||
await receiver.decryptChunk(bytes);
|
||||
await expect(receiver.decryptChunk(bytes)).rejects.toThrow(StreamReplayError);
|
||||
});
|
||||
|
||||
test('out-of-order chunk (skipping seq) → StreamOutOfOrderError', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
await sender.encryptChunk(new TextEncoder().encode('a'), false); // seq 0
|
||||
const { bytes: c1 } = await sender.encryptChunk(new TextEncoder().encode('b'), false); // seq 1
|
||||
// Skip seq 0; send seq 1 first
|
||||
await expect(receiver.decryptChunk(c1)).rejects.toThrow(StreamOutOfOrderError);
|
||||
});
|
||||
|
||||
test('error contains expected and received seq', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
await sender.encryptChunk(new TextEncoder().encode('skip'), false); // seq 0 produced
|
||||
const { bytes: c1 } = await sender.encryptChunk(new TextEncoder().encode('next'), false);
|
||||
try {
|
||||
await receiver.decryptChunk(c1);
|
||||
throw new Error('expected throw');
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain('expected seq=0');
|
||||
expect((err as Error).message).toContain('got 1');
|
||||
}
|
||||
});
|
||||
|
||||
test('out-of-order then in-order: in-order chunk (after error) still works', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes: c0 } = await sender.encryptChunk(new TextEncoder().encode('a'), false);
|
||||
const { bytes: c1 } = await sender.encryptChunk(new TextEncoder().encode('b'), true);
|
||||
await expect(receiver.decryptChunk(c1)).rejects.toThrow(StreamOutOfOrderError);
|
||||
// Receiver state still expects seq 0 (the error did not advance it)
|
||||
const dec0 = await receiver.decryptChunk(c0);
|
||||
expect(dec0.seq).toBe(0);
|
||||
const dec1 = await receiver.decryptChunk(c1);
|
||||
expect(dec1.seq).toBe(1);
|
||||
});
|
||||
|
||||
test('replay after a different in-order chunk advanced seq → StreamReplayError', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes: c0 } = await sender.encryptChunk(new TextEncoder().encode('a'), false);
|
||||
const { bytes: c1 } = await sender.encryptChunk(new TextEncoder().encode('b'), false);
|
||||
await receiver.decryptChunk(c0);
|
||||
await receiver.decryptChunk(c1);
|
||||
await expect(receiver.decryptChunk(c0)).rejects.toThrow(StreamReplayError);
|
||||
});
|
||||
});
|
||||
176
packages/shade-streams/tests/sender-receiver.test.ts
Normal file
176
packages/shade-streams/tests/sender-receiver.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
StreamSender,
|
||||
StreamReceiver,
|
||||
StreamFinishedError,
|
||||
generateStreamId,
|
||||
generateStreamSecret,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
function hex(b: Uint8Array): string {
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function makePair(opts?: { laneId?: number; startSeq?: number }) {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const streamSecret = generateStreamSecret(crypto);
|
||||
const laneId = opts?.laneId ?? 0;
|
||||
const sender = await StreamSender.create({
|
||||
crypto,
|
||||
streamId,
|
||||
streamSecret,
|
||||
laneId,
|
||||
...(opts?.startSeq !== undefined ? { startSeq: opts.startSeq } : {}),
|
||||
});
|
||||
const receiver = await StreamReceiver.create({
|
||||
crypto,
|
||||
streamId,
|
||||
streamSecret,
|
||||
laneId,
|
||||
...(opts?.startSeq !== undefined ? { startSeq: opts.startSeq } : {}),
|
||||
});
|
||||
return { sender, receiver, streamId, streamSecret };
|
||||
}
|
||||
|
||||
describe('Single-lane sender/receiver roundtrip', () => {
|
||||
test('basic single-chunk transfer', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
const plaintext = new TextEncoder().encode('hello shade');
|
||||
const { bytes } = await sender.encryptChunk(plaintext, true);
|
||||
const decrypted = await receiver.decryptChunk(bytes);
|
||||
expect(new TextDecoder().decode(decrypted.plaintext)).toBe('hello shade');
|
||||
expect(decrypted.seq).toBe(0);
|
||||
expect(decrypted.isLast).toBe(true);
|
||||
});
|
||||
|
||||
test('multi-chunk transfer with monotonic seq', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
const chunks = ['alpha', 'beta', 'gamma', 'delta'];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const isLast = i === chunks.length - 1;
|
||||
const { bytes, seq } = await sender.encryptChunk(
|
||||
new TextEncoder().encode(chunks[i]!),
|
||||
isLast,
|
||||
);
|
||||
expect(seq).toBe(i);
|
||||
const dec = await receiver.decryptChunk(bytes);
|
||||
expect(new TextDecoder().decode(dec.plaintext)).toBe(chunks[i]);
|
||||
expect(dec.seq).toBe(i);
|
||||
expect(dec.isLast).toBe(isLast);
|
||||
}
|
||||
});
|
||||
|
||||
test('lane sha256 matches between sender and receiver', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
const data = [
|
||||
crypto.randomBytes(1024),
|
||||
crypto.randomBytes(2048),
|
||||
crypto.randomBytes(512),
|
||||
];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const { bytes } = await sender.encryptChunk(data[i]!, i === data.length - 1);
|
||||
await receiver.decryptChunk(bytes);
|
||||
}
|
||||
expect(hex(sender.getLaneSha256Digest())).toBe(hex(receiver.getLaneSha256Digest()));
|
||||
});
|
||||
|
||||
test('handles empty chunks', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
const { bytes } = await sender.encryptChunk(new Uint8Array(0), true);
|
||||
const dec = await receiver.decryptChunk(bytes);
|
||||
expect(dec.plaintext.length).toBe(0);
|
||||
expect(dec.isLast).toBe(true);
|
||||
});
|
||||
|
||||
test('ship-gate: ~10 MiB roundtrip preserves byte-for-byte content', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
const total = 10 * 1024 * 1024;
|
||||
const chunkSize = 256 * 1024;
|
||||
const allBytes = crypto.randomBytes(total);
|
||||
|
||||
const reconstructed: Uint8Array[] = [];
|
||||
for (let off = 0; off < total; off += chunkSize) {
|
||||
const slice = allBytes.subarray(off, Math.min(off + chunkSize, total));
|
||||
const isLast = off + chunkSize >= total;
|
||||
const { bytes } = await sender.encryptChunk(slice, isLast);
|
||||
const dec = await receiver.decryptChunk(bytes);
|
||||
reconstructed.push(dec.plaintext);
|
||||
}
|
||||
|
||||
let off = 0;
|
||||
for (const piece of reconstructed) {
|
||||
for (let i = 0; i < piece.length; i++) {
|
||||
if (piece[i] !== allBytes[off + i]) {
|
||||
throw new Error(`mismatch at byte ${off + i}`);
|
||||
}
|
||||
}
|
||||
off += piece.length;
|
||||
}
|
||||
expect(off).toBe(total);
|
||||
expect(hex(sender.getLaneSha256Digest())).toBe(hex(receiver.getLaneSha256Digest()));
|
||||
});
|
||||
|
||||
test('byte counters track encrypted/decrypted plaintext', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
const a = crypto.randomBytes(100);
|
||||
const b = crypto.randomBytes(250);
|
||||
const { bytes: ab } = await sender.encryptChunk(a, false);
|
||||
const { bytes: bb } = await sender.encryptChunk(b, true);
|
||||
await receiver.decryptChunk(ab);
|
||||
await receiver.decryptChunk(bb);
|
||||
expect(sender.bytesSent).toBe(350n);
|
||||
expect(receiver.bytesReceived).toBe(350n);
|
||||
});
|
||||
|
||||
test('finished flag set after isLast', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
const { bytes } = await sender.encryptChunk(new Uint8Array(8), true);
|
||||
expect(sender.isFinished).toBe(true);
|
||||
await receiver.decryptChunk(bytes);
|
||||
expect(receiver.isFinished).toBe(true);
|
||||
});
|
||||
|
||||
test('encryptChunk after finish throws StreamFinishedError', async () => {
|
||||
const { sender } = await makePair();
|
||||
await sender.encryptChunk(new Uint8Array(0), true);
|
||||
await expect(sender.encryptChunk(new Uint8Array(0), false)).rejects.toThrow(
|
||||
StreamFinishedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('decryptChunk after finish throws StreamFinishedError', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
const { bytes: a } = await sender.encryptChunk(new Uint8Array(8), true);
|
||||
await receiver.decryptChunk(a);
|
||||
// Try to feed another chunk — sender wouldn't normally produce one, but
|
||||
// simulate an attacker sending bytes after the legitimate isLast.
|
||||
const sender2 = await StreamSender.create({
|
||||
crypto,
|
||||
streamId: (sender as unknown as { streamId: Uint8Array }).streamId,
|
||||
streamSecret: new Uint8Array(32),
|
||||
laneId: 0,
|
||||
});
|
||||
const { bytes: extra } = await sender2.encryptChunk(new Uint8Array(8), false);
|
||||
await expect(receiver.decryptChunk(extra)).rejects.toThrow(StreamFinishedError);
|
||||
});
|
||||
|
||||
test('destroy zeroes the lane key (subsequent calls throw)', async () => {
|
||||
const { sender, receiver } = await makePair();
|
||||
sender.destroy();
|
||||
receiver.destroy();
|
||||
await expect(sender.encryptChunk(new Uint8Array(0), false)).rejects.toThrow(
|
||||
StreamFinishedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('startSeq enables resume from arbitrary offset', async () => {
|
||||
const { sender, receiver } = await makePair({ startSeq: 100 });
|
||||
const { bytes, seq } = await sender.encryptChunk(new TextEncoder().encode('mid'), false);
|
||||
expect(seq).toBe(100);
|
||||
const dec = await receiver.decryptChunk(bytes);
|
||||
expect(dec.seq).toBe(100);
|
||||
});
|
||||
});
|
||||
109
packages/shade-streams/tests/tamper.test.ts
Normal file
109
packages/shade-streams/tests/tamper.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { decodeStreamChunk, encodeStreamChunk } from '@shade/proto';
|
||||
import {
|
||||
StreamSender,
|
||||
StreamReceiver,
|
||||
StreamDecryptionError,
|
||||
StreamProtocolError,
|
||||
generateStreamId,
|
||||
generateStreamSecret,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
async function pair() {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const streamSecret = generateStreamSecret(crypto);
|
||||
const sender = await StreamSender.create({ crypto, streamId, streamSecret, laneId: 0 });
|
||||
const receiver = await StreamReceiver.create({ crypto, streamId, streamSecret, laneId: 0 });
|
||||
return { sender, receiver };
|
||||
}
|
||||
|
||||
describe('Tamper detection', () => {
|
||||
test('flipping a ciphertext byte → StreamDecryptionError', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||||
const env = decodeStreamChunk(bytes);
|
||||
env.ciphertext[0] ^= 0x01;
|
||||
const reencoded = encodeStreamChunk(env);
|
||||
await expect(receiver.decryptChunk(reencoded)).rejects.toThrow(StreamDecryptionError);
|
||||
});
|
||||
|
||||
test('flipping the AEAD tag → StreamDecryptionError', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||||
const env = decodeStreamChunk(bytes);
|
||||
env.ciphertext[env.ciphertext.length - 1] ^= 0x80;
|
||||
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||||
StreamDecryptionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('tampering with isLast flag → StreamDecryptionError (AAD mismatch)', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||||
const env = decodeStreamChunk(bytes);
|
||||
env.isLast = true;
|
||||
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||||
StreamDecryptionError,
|
||||
);
|
||||
});
|
||||
|
||||
test('tampering with the wire nonce → StreamProtocolError (deterministic check)', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||||
const env = decodeStreamChunk(bytes);
|
||||
env.nonce[0] ^= 0x01;
|
||||
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||||
StreamProtocolError,
|
||||
);
|
||||
});
|
||||
|
||||
test('tampering with streamId → StreamProtocolError', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||||
const env = decodeStreamChunk(bytes);
|
||||
env.streamId[0] ^= 0xff;
|
||||
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||||
StreamProtocolError,
|
||||
);
|
||||
});
|
||||
|
||||
test('routing a chunk to wrong-lane receiver → StreamProtocolError', async () => {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const streamSecret = generateStreamSecret(crypto);
|
||||
const sender0 = await StreamSender.create({ crypto, streamId, streamSecret, laneId: 0 });
|
||||
const receiver1 = await StreamReceiver.create({ crypto, streamId, streamSecret, laneId: 1 });
|
||||
const { bytes } = await sender0.encryptChunk(new TextEncoder().encode('payload'), false);
|
||||
await expect(receiver1.decryptChunk(bytes)).rejects.toThrow(StreamProtocolError);
|
||||
});
|
||||
|
||||
test('non-empty AAD on wire → StreamProtocolError (reserved in v0.2.0)', async () => {
|
||||
const { sender, receiver } = await pair();
|
||||
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||||
const env = decodeStreamChunk(bytes);
|
||||
env.aad = new Uint8Array([1, 2, 3]);
|
||||
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||||
StreamProtocolError,
|
||||
);
|
||||
});
|
||||
|
||||
test('different streamSecret → StreamDecryptionError', async () => {
|
||||
const streamId = generateStreamId(crypto);
|
||||
const sender = await StreamSender.create({
|
||||
crypto,
|
||||
streamId,
|
||||
streamSecret: new Uint8Array(32).fill(1),
|
||||
laneId: 0,
|
||||
});
|
||||
const receiver = await StreamReceiver.create({
|
||||
crypto,
|
||||
streamId,
|
||||
streamSecret: new Uint8Array(32).fill(2),
|
||||
laneId: 0,
|
||||
});
|
||||
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('hi'), false);
|
||||
await expect(receiver.decryptChunk(bytes)).rejects.toThrow(StreamDecryptionError);
|
||||
});
|
||||
});
|
||||
8
packages/shade-streams/tsconfig.json
Normal file
8
packages/shade-streams/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user