release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -1,13 +1,15 @@
{
"name": "@shade/streams",
"version": "0.3.0",
"version": "4.0.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:*"
},
"devDependencies": {
"@shade/crypto-web": "workspace:*"
}
}

View File

@@ -7,6 +7,7 @@
* Stream-chunk uses dedicated wire type 0x11 (see `@shade/proto/wire.ts`).
*/
import { ValidationError } from '@shade/core';
import { validateFileMetadata } from './file-metadata.js';
import type { LaneInitSpec, StreamMetadata } from './types.js';
export type StreamControlKind =
@@ -94,10 +95,16 @@ export function parseStreamControl(plaintext: string): StreamControlMessage {
if (!isStreamControlMessage(parsed)) {
throw new ValidationError('plaintext is not a stream control message', 'plaintext');
}
if (parsed.kind === 'shade.stream-init/v1' && parsed.metadata?.fileMetadata !== undefined) {
validateFileMetadata(parsed.metadata.fileMetadata);
}
return parsed;
}
/** Encode a stream control message as JSON; throws on circular refs. */
export function encodeStreamControl(msg: StreamControlMessage): string {
if (msg.kind === 'shade.stream-init/v1' && msg.metadata?.fileMetadata !== undefined) {
validateFileMetadata(msg.metadata.fileMetadata);
}
return JSON.stringify(msg);
}

View File

@@ -0,0 +1,146 @@
/**
* V3.9 — Rich file metadata helpers.
*
* `StreamFileMetadata` rides inside `stream-init` plaintext and is therefore
* E2EE; consumers that opt into thumbnails ship the preview as a *separate*
* stream (id `${main}.thumb`) keyed independently. The format-hardening
* rules (size + MIME allowlist) are enforced *symmetrically* on sender and
* receiver so a hostile peer cannot bypass them by forging
* `fileMetadata` — the receiver re-validates before rendering.
*/
import { ValidationError } from '@shade/core';
import {
THUMBNAIL_MAX_BYTES,
THUMBNAIL_MIME_ALLOWLIST,
type StreamFileMetadata,
type ThumbnailMime,
} from './types.js';
/** Suffix used for the companion thumbnail-stream id. */
export const THUMBNAIL_STREAM_ID_SUFFIX = '.thumb';
/** Compute the thumbnail-stream id given the main streamId. */
export function thumbnailStreamIdFor(mainStreamId: string): string {
return `${mainStreamId}${THUMBNAIL_STREAM_ID_SUFFIX}`;
}
/** Inverse: given a streamId, peel `.thumb`. Returns null if not a thumb. */
export function mainStreamIdForThumbnail(streamId: string): string | null {
if (!streamId.endsWith(THUMBNAIL_STREAM_ID_SUFFIX)) return null;
return streamId.slice(0, -THUMBNAIL_STREAM_ID_SUFFIX.length);
}
/** Type guard for the MIME allowlist. */
export function isAllowedThumbnailMime(mime: string): mime is ThumbnailMime {
return (THUMBNAIL_MIME_ALLOWLIST as readonly string[]).includes(mime);
}
/**
* Validate a `StreamFileMetadata` value. Throws `ValidationError` on a
* shape mismatch. All fields are optional, so an empty object is valid.
*
* The same function runs on the sender (before encoding init) and the
* receiver (after decoding) — single source of truth for the format
* rules.
*/
export function validateFileMetadata(meta: StreamFileMetadata): void {
if (typeof meta !== 'object' || meta === null) {
throw new ValidationError('fileMetadata must be an object', 'fileMetadata');
}
if (meta.filename !== undefined) {
if (typeof meta.filename !== 'string') {
throw new ValidationError('filename must be a string', 'fileMetadata.filename');
}
if (meta.filename.length > 1024) {
throw new ValidationError(
'filename exceeds 1024 chars',
'fileMetadata.filename',
);
}
// Strip control chars + path separators — these have no place in a
// displayable filename and a hostile sender shouldn't be able to make
// a UI render `\r\n` or `../`.
if (/[\x00-\x1f\x7f]/.test(meta.filename)) {
throw new ValidationError(
'filename contains control characters',
'fileMetadata.filename',
);
}
}
if (meta.mimeType !== undefined) {
if (typeof meta.mimeType !== 'string' || meta.mimeType.length > 256) {
throw new ValidationError(
'mimeType must be a non-empty string ≤ 256 chars',
'fileMetadata.mimeType',
);
}
if (!/^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/.test(meta.mimeType)) {
throw new ValidationError(
'mimeType is not a well-formed media-type token',
'fileMetadata.mimeType',
);
}
}
if (meta.thumbnailMime !== undefined && !isAllowedThumbnailMime(meta.thumbnailMime)) {
throw new ValidationError(
`thumbnailMime ${meta.thumbnailMime} not in allowlist`,
'fileMetadata.thumbnailMime',
);
}
if (meta.thumbnailBytes !== undefined) {
if (
!Number.isInteger(meta.thumbnailBytes) ||
meta.thumbnailBytes < 0 ||
meta.thumbnailBytes > THUMBNAIL_MAX_BYTES
) {
throw new ValidationError(
`thumbnailBytes out of range [0, ${THUMBNAIL_MAX_BYTES}]`,
'fileMetadata.thumbnailBytes',
);
}
}
if (meta.thumbnailHash !== undefined) {
if (typeof meta.thumbnailHash !== 'string' || meta.thumbnailHash.length === 0) {
throw new ValidationError(
'thumbnailHash must be a non-empty base64 string',
'fileMetadata.thumbnailHash',
);
}
}
if (meta.thumbnailStreamId !== undefined) {
if (
typeof meta.thumbnailStreamId !== 'string' ||
meta.thumbnailStreamId.length === 0 ||
meta.thumbnailStreamId.length > 64
) {
throw new ValidationError(
'thumbnailStreamId must be a non-empty short string',
'fileMetadata.thumbnailStreamId',
);
}
}
// Cross-field check: hash without bytes (or vice versa) is suspicious.
// We allow `thumbnailHash` alone for backwards compatibility with future
// shapes, but require bytes when MIME is declared.
if (meta.thumbnailMime !== undefined && meta.thumbnailBytes === undefined) {
throw new ValidationError(
'thumbnailMime declared without thumbnailBytes',
'fileMetadata',
);
}
}
/**
* Return true when the metadata declares a thumbnail-stream that the
* receiver should fetch.
*/
export function declaresThumbnail(meta: StreamFileMetadata | undefined): boolean {
return (
meta !== undefined &&
meta.thumbnailHash !== undefined &&
meta.thumbnailMime !== undefined &&
meta.thumbnailBytes !== undefined &&
meta.thumbnailStreamId !== undefined
);
}

View File

@@ -1,5 +1,6 @@
export * from './errors.js';
export * from './types.js';
export * from './file-metadata.js';
export * from './ids.js';
export * from './kdf.js';
export * from './nonce.js';

View File

@@ -5,6 +5,56 @@
* progress, etc.) live in @shade/transfer.
*/
/**
* Allow-listed thumbnail MIME types (V3.9). The receiver MUST refuse to
* render thumbnails declaring a MIME outside this set so a hostile sender
* cannot smuggle exotic formats past the preview-renderer.
*/
export const THUMBNAIL_MIME_ALLOWLIST = [
'image/jpeg',
'image/webp',
'image/png',
] as const;
export type ThumbnailMime = (typeof THUMBNAIL_MIME_ALLOWLIST)[number];
/**
* Hard cap on a thumbnail stream's plaintext size (V3.9). 64 KiB covers a
* 256x256 high-quality WebP/JPEG with headroom; oversized declarations are
* rejected before any bytes hit the receiver's renderer.
*/
export const THUMBNAIL_MAX_BYTES = 64 * 1024;
/**
* Optional E2EE file metadata (V3.9). Carried inside the existing
* `stream-init` control envelope plaintext; backwards-compatible — older
* receivers that do not understand the field simply ignore it.
*
* Bytes that are sensitive (`filename`, `mimeType`) ride inside the
* Double Ratchet plaintext, so the server only ever sees the bin'd
* `totalBytes`. The thumbnail itself is shipped as a *separate* stream
* (with id `${mainStreamId}.thumb`) keyed independently so a server
* compromise leaks neither preview pixels nor declared filename.
*/
export interface StreamFileMetadata {
/** Original filename. E2EE — never visible to the server. */
filename?: string;
/** Content MIME type (e.g. `application/pdf`). E2EE. */
mimeType?: string;
/**
* base64url streamId of the companion thumbnail stream. Receivers use
* this to correlate the inbound thumbnail transfer with this main
* transfer. Always shipped alongside `thumbnailHash` /
* `thumbnailMime` / `thumbnailBytes`.
*/
thumbnailStreamId?: string;
/** sha256 of the separate thumbnail-stream's plaintext, base64. */
thumbnailHash?: string;
/** Declared MIME of the thumbnail stream (must be in `THUMBNAIL_MIME_ALLOWLIST`). */
thumbnailMime?: ThumbnailMime;
/** Declared plaintext byte length of the thumbnail (≤ `THUMBNAIL_MAX_BYTES`). */
thumbnailBytes?: number;
}
/** Plaintext metadata sent in a stream-init control envelope. */
export interface StreamMetadata {
name?: string;
@@ -25,6 +75,11 @@ export interface StreamMetadata {
* RPC). The transport itself does not interpret these values.
*/
userMetadata?: Record<string, string>;
/**
* V3.9 — optional E2EE file metadata. Older receivers ignore this field;
* widgets and `@shade/files` consult it to render filename / preview.
*/
fileMetadata?: StreamFileMetadata;
}
/** Per-lane partition assignment carried in stream-init. */

Binary file not shown.