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
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:
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
146
packages/shade-streams/src/file-metadata.ts
Normal file
146
packages/shade-streams/src/file-metadata.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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. */
|
||||
|
||||
BIN
packages/shade-streams/tests/file-metadata.test.ts
Normal file
BIN
packages/shade-streams/tests/file-metadata.test.ts
Normal file
Binary file not shown.
Reference in New Issue
Block a user