feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
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:
2026-05-02 14:00:01 +02:00
parent 7e0f7320a9
commit fa770d3063
198 changed files with 20412 additions and 256 deletions

View 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();
});
});

View 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);
});
});

View 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);
});
});

View 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',
);
});
});

View 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);
});
});

View 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));
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});