feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
import type { CryptoProvider } from './crypto.js';
|
|
|
|
|
import type { StorageProvider } from './storage.js';
|
|
|
|
|
import type {
|
|
|
|
|
IdentityKeyPair,
|
|
|
|
|
SignedPreKey,
|
|
|
|
|
OneTimePreKey,
|
|
|
|
|
PreKeyBundle,
|
|
|
|
|
PreKeyMessage,
|
|
|
|
|
RatchetMessage,
|
|
|
|
|
} from './types.js';
|
|
|
|
|
import { deriveInitialRootKey } from './keys.js';
|
feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
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>
2026-05-02 14:00:01 +02:00
|
|
|
import { InvalidSignatureError, PreKeyNotFoundError } from './errors.js';
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* X3DH — Extended Triple Diffie-Hellman key agreement.
|
|
|
|
|
*
|
|
|
|
|
* Establishes a shared secret between two parties (Alice and Bob) even when
|
|
|
|
|
* Bob is offline, using prekey bundles published to a server.
|
|
|
|
|
*
|
|
|
|
|
* Reference: https://signal.org/docs/specifications/x3dh/
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// ─── Key Generation ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** Generate a new identity keypair: Ed25519 (signing) + X25519 (DH) */
|
|
|
|
|
export async function generateIdentityKeyPair(crypto: CryptoProvider): Promise<IdentityKeyPair> {
|
|
|
|
|
const [signing, dh] = await Promise.all([
|
|
|
|
|
crypto.generateEd25519KeyPair(),
|
|
|
|
|
crypto.generateX25519KeyPair(),
|
|
|
|
|
]);
|
|
|
|
|
return {
|
|
|
|
|
signingPublicKey: signing.publicKey,
|
|
|
|
|
signingPrivateKey: signing.privateKey,
|
|
|
|
|
dhPublicKey: dh.publicKey,
|
|
|
|
|
dhPrivateKey: dh.privateKey,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Generate a signed prekey: X25519 keypair signed by the identity key */
|
|
|
|
|
export async function generateSignedPreKey(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
identityKey: IdentityKeyPair,
|
|
|
|
|
keyId: number,
|
|
|
|
|
): Promise<SignedPreKey> {
|
|
|
|
|
const keyPair = await crypto.generateX25519KeyPair();
|
|
|
|
|
const signature = await crypto.sign(identityKey.signingPrivateKey, keyPair.publicKey);
|
|
|
|
|
return {
|
|
|
|
|
keyId,
|
|
|
|
|
keyPair,
|
|
|
|
|
signature,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Generate a batch of one-time prekeys */
|
|
|
|
|
export async function generateOneTimePreKeys(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
startId: number,
|
|
|
|
|
count: number,
|
|
|
|
|
): Promise<OneTimePreKey[]> {
|
|
|
|
|
const keys: OneTimePreKey[] = [];
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
const keyPair = await crypto.generateX25519KeyPair();
|
|
|
|
|
keys.push({ keyId: startId + i, keyPair });
|
|
|
|
|
}
|
|
|
|
|
return keys;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Assemble a prekey bundle for publishing to the prekey server */
|
|
|
|
|
export function createPreKeyBundle(
|
|
|
|
|
registrationId: number,
|
|
|
|
|
identityKey: IdentityKeyPair,
|
|
|
|
|
signedPreKey: SignedPreKey,
|
|
|
|
|
oneTimePreKey?: OneTimePreKey,
|
|
|
|
|
): PreKeyBundle {
|
feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
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>
2026-05-02 14:00:01 +02:00
|
|
|
const bundle: PreKeyBundle = {
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
registrationId,
|
|
|
|
|
identitySigningKey: identityKey.signingPublicKey,
|
|
|
|
|
identityDHKey: identityKey.dhPublicKey,
|
|
|
|
|
signedPreKey: {
|
|
|
|
|
keyId: signedPreKey.keyId,
|
|
|
|
|
publicKey: signedPreKey.keyPair.publicKey,
|
|
|
|
|
signature: signedPreKey.signature,
|
|
|
|
|
},
|
|
|
|
|
};
|
feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
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>
2026-05-02 14:00:01 +02:00
|
|
|
if (oneTimePreKey) {
|
|
|
|
|
bundle.oneTimePreKey = {
|
|
|
|
|
keyId: oneTimePreKey.keyId,
|
|
|
|
|
publicKey: oneTimePreKey.keyPair.publicKey,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return bundle;
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Alice: Initiate Session ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process a prekey bundle to establish a new session (Alice's side).
|
|
|
|
|
*
|
|
|
|
|
* Steps:
|
|
|
|
|
* 1. Verify the signed prekey's signature using Bob's identity signing key
|
|
|
|
|
* 2. Generate an ephemeral X25519 keypair
|
|
|
|
|
* 3. Compute 3 or 4 DH shared secrets:
|
|
|
|
|
* DH1 = DH(Alice identity DH, Bob signed prekey)
|
|
|
|
|
* DH2 = DH(Alice ephemeral, Bob identity DH)
|
|
|
|
|
* DH3 = DH(Alice ephemeral, Bob signed prekey)
|
|
|
|
|
* DH4 = DH(Alice ephemeral, Bob one-time prekey) — if available
|
|
|
|
|
* 4. Derive the initial root key from the concatenated DH outputs
|
|
|
|
|
* 5. Return the X3DH result with all info needed to create a session + PreKeyMessage
|
|
|
|
|
*/
|
|
|
|
|
export interface X3DHInitResult {
|
|
|
|
|
/** Initial root key (32 bytes) — seeds the Double Ratchet */
|
|
|
|
|
rootKey: Uint8Array;
|
|
|
|
|
/** Alice's ephemeral X25519 public key (sent to Bob in the PreKeyMessage) */
|
|
|
|
|
ephemeralPublicKey: Uint8Array;
|
|
|
|
|
/** Bob's signed prekey ID (so Bob can look up the key) */
|
|
|
|
|
signedPreKeyId: number;
|
|
|
|
|
/** Bob's one-time prekey ID if consumed */
|
|
|
|
|
preKeyId?: number;
|
|
|
|
|
/** Bob's identity DH public key */
|
|
|
|
|
remoteIdentityKey: Uint8Array;
|
|
|
|
|
/** Bob's signed prekey public key (used as initial DH ratchet remote key) */
|
|
|
|
|
remoteSignedPreKey: Uint8Array;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function processPreKeyBundle(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
storage: StorageProvider,
|
|
|
|
|
bundle: PreKeyBundle,
|
|
|
|
|
): Promise<X3DHInitResult> {
|
|
|
|
|
// 1. Verify signed prekey signature
|
|
|
|
|
const valid = await crypto.verify(
|
|
|
|
|
bundle.identitySigningKey,
|
|
|
|
|
bundle.signedPreKey.publicKey,
|
|
|
|
|
bundle.signedPreKey.signature,
|
|
|
|
|
);
|
|
|
|
|
if (!valid) {
|
|
|
|
|
throw new InvalidSignatureError('Signed prekey signature is invalid');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Check trust (TOFU or pinned)
|
|
|
|
|
// We trust based on the DH key since that's what we use in the protocol
|
|
|
|
|
const identityKey = await storage.getIdentityKeyPair();
|
|
|
|
|
if (!identityKey) throw new Error('No local identity key — call initialize() first');
|
|
|
|
|
|
|
|
|
|
// 3. Generate ephemeral keypair
|
|
|
|
|
const ephemeral = await crypto.generateX25519KeyPair();
|
|
|
|
|
|
|
|
|
|
// 4. Compute DH shared secrets
|
|
|
|
|
const dh1 = await crypto.x25519(identityKey.dhPrivateKey, bundle.signedPreKey.publicKey);
|
|
|
|
|
const dh2 = await crypto.x25519(ephemeral.privateKey, bundle.identityDHKey);
|
|
|
|
|
const dh3 = await crypto.x25519(ephemeral.privateKey, bundle.signedPreKey.publicKey);
|
|
|
|
|
|
|
|
|
|
const secrets = [dh1, dh2, dh3];
|
|
|
|
|
|
|
|
|
|
let preKeyId: number | undefined;
|
|
|
|
|
if (bundle.oneTimePreKey) {
|
|
|
|
|
const dh4 = await crypto.x25519(ephemeral.privateKey, bundle.oneTimePreKey.publicKey);
|
|
|
|
|
secrets.push(dh4);
|
|
|
|
|
preKeyId = bundle.oneTimePreKey.keyId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. Derive initial root key
|
|
|
|
|
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
|
|
|
|
|
|
|
|
|
// 6. Save trust for remote identity
|
|
|
|
|
await storage.saveTrustedIdentity('pending', bundle.identityDHKey);
|
|
|
|
|
|
feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
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>
2026-05-02 14:00:01 +02:00
|
|
|
const result: X3DHInitResult = {
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
rootKey,
|
|
|
|
|
ephemeralPublicKey: ephemeral.publicKey,
|
|
|
|
|
signedPreKeyId: bundle.signedPreKey.keyId,
|
|
|
|
|
remoteIdentityKey: bundle.identityDHKey,
|
|
|
|
|
remoteSignedPreKey: bundle.signedPreKey.publicKey,
|
|
|
|
|
};
|
feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
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>
2026-05-02 14:00:01 +02:00
|
|
|
if (preKeyId !== undefined) result.preKeyId = preKeyId;
|
|
|
|
|
return result;
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Bob: Respond to PreKeyMessage ───────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process an incoming PreKeyMessage to establish a session (Bob's side).
|
|
|
|
|
*
|
|
|
|
|
* Steps:
|
|
|
|
|
* 1. Look up the signed prekey and optionally the one-time prekey
|
|
|
|
|
* 2. Compute the same 3 or 4 DH shared secrets (from Bob's perspective)
|
|
|
|
|
* 3. Derive the same initial root key
|
|
|
|
|
* 4. Delete the consumed one-time prekey
|
|
|
|
|
* 5. Return the X3DH result to seed the Double Ratchet
|
|
|
|
|
*/
|
|
|
|
|
export interface X3DHResponseResult {
|
|
|
|
|
/** Initial root key (32 bytes) — must match Alice's */
|
|
|
|
|
rootKey: Uint8Array;
|
|
|
|
|
/** Alice's identity DH public key */
|
|
|
|
|
remoteIdentityKey: Uint8Array;
|
|
|
|
|
/** Alice's ephemeral public key (used as initial DH ratchet remote key) */
|
|
|
|
|
remoteEphemeralKey: Uint8Array;
|
|
|
|
|
/** The embedded first ratchet message to decrypt */
|
|
|
|
|
initialMessage: RatchetMessage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function processPreKeyMessage(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
storage: StorageProvider,
|
|
|
|
|
message: PreKeyMessage,
|
|
|
|
|
): Promise<X3DHResponseResult> {
|
|
|
|
|
const identityKey = await storage.getIdentityKeyPair();
|
|
|
|
|
if (!identityKey) throw new Error('No local identity key — call initialize() first');
|
|
|
|
|
|
|
|
|
|
// 1. Look up signed prekey
|
|
|
|
|
const signedPreKey = await storage.getSignedPreKey(message.signedPreKeyId);
|
|
|
|
|
if (!signedPreKey) {
|
|
|
|
|
throw new PreKeyNotFoundError(message.signedPreKeyId, 'signed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Compute DH shared secrets (Bob's perspective — mirrored from Alice)
|
|
|
|
|
const dh1 = await crypto.x25519(signedPreKey.keyPair.privateKey, message.identityDHKey);
|
|
|
|
|
const dh2 = await crypto.x25519(identityKey.dhPrivateKey, message.ephemeralKey);
|
|
|
|
|
const dh3 = await crypto.x25519(signedPreKey.keyPair.privateKey, message.ephemeralKey);
|
|
|
|
|
|
|
|
|
|
const secrets = [dh1, dh2, dh3];
|
|
|
|
|
|
|
|
|
|
// 3. If a one-time prekey was used, include DH4
|
|
|
|
|
if (message.preKeyId != null) {
|
|
|
|
|
const oneTimePreKey = await storage.getOneTimePreKey(message.preKeyId);
|
|
|
|
|
if (!oneTimePreKey) {
|
|
|
|
|
throw new PreKeyNotFoundError(message.preKeyId, 'one-time');
|
|
|
|
|
}
|
|
|
|
|
const dh4 = await crypto.x25519(oneTimePreKey.keyPair.privateKey, message.ephemeralKey);
|
|
|
|
|
secrets.push(dh4);
|
|
|
|
|
|
|
|
|
|
// 4. Consume (delete) the one-time prekey
|
|
|
|
|
await storage.removeOneTimePreKey(message.preKeyId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. Derive the initial root key (should match Alice's)
|
|
|
|
|
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
|
|
|
|
|
|
|
|
|
// 6. Save trust for remote identity
|
|
|
|
|
await storage.saveTrustedIdentity('pending', message.identityDHKey);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
rootKey,
|
|
|
|
|
remoteIdentityKey: message.identityDHKey,
|
|
|
|
|
remoteEphemeralKey: message.ephemeralKey,
|
|
|
|
|
initialMessage: message.message,
|
|
|
|
|
};
|
|
|
|
|
}
|