2026-04-10 17:51:29 +02:00
|
|
|
import postgres, { type Sql } from 'postgres';
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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>
2026-05-03 18:35:35 +02:00
|
|
|
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState, PeerVerification, PeerVerificationSource } from '@shade/core';
|
2026-04-10 17:51:29 +02:00
|
|
|
import {
|
|
|
|
|
toBase64, fromBase64,
|
|
|
|
|
constantTimeEqual,
|
|
|
|
|
serializeSessionState, deserializeSessionState,
|
|
|
|
|
serializeSignedPreKey, deserializeSignedPreKey,
|
|
|
|
|
serializeOneTimePreKey, deserializeOneTimePreKey,
|
|
|
|
|
serializeIdentityKeyPair, deserializeIdentityKeyPair,
|
|
|
|
|
} from '@shade/core';
|
|
|
|
|
import { ensureClientTables } from './ensure-tables.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PostgreSQL-backed StorageProvider for Shade client-side storage.
|
|
|
|
|
*
|
|
|
|
|
* Schema: tables prefixed `shade_` so they don't collide with existing
|
|
|
|
|
* tables in projects that share the same PG instance (e.g. Orchestrator).
|
|
|
|
|
*
|
|
|
|
|
* Usage:
|
|
|
|
|
* ```ts
|
|
|
|
|
* const storage = await PostgresStorage.create('postgres://user:pass@host/db');
|
|
|
|
|
* const manager = new ShadeSessionManager(crypto, storage);
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
|
|
|
|
export class PostgresStorage implements StorageProvider {
|
|
|
|
|
private constructor(
|
|
|
|
|
private readonly sql: Sql,
|
|
|
|
|
private readonly ownsConnection: boolean,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/** Create from a connection string (owns the connection) */
|
|
|
|
|
static async create(connectionString: string): Promise<PostgresStorage> {
|
|
|
|
|
const sql = postgres(connectionString);
|
|
|
|
|
const storage = new PostgresStorage(sql, true);
|
|
|
|
|
await ensureClientTables(sql);
|
|
|
|
|
return storage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Create from an existing postgres-js client (caller owns the connection) */
|
|
|
|
|
static async fromClient(sql: Sql): Promise<PostgresStorage> {
|
|
|
|
|
const storage = new PostgresStorage(sql, false);
|
|
|
|
|
await ensureClientTables(sql);
|
|
|
|
|
return storage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async close(): Promise<void> {
|
|
|
|
|
if (this.ownsConnection) await this.sql.end();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Identity ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getIdentityKeyPair(): Promise<IdentityKeyPair | null> {
|
|
|
|
|
const rows = await this.sql<Array<{ signing_public_key: string; signing_private_key: string; dh_public_key: string; dh_private_key: string }>>`
|
|
|
|
|
SELECT signing_public_key, signing_private_key, dh_public_key, dh_private_key
|
|
|
|
|
FROM shade_identity WHERE id = 1
|
|
|
|
|
`;
|
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
|
const r = rows[0]!;
|
|
|
|
|
return {
|
|
|
|
|
signingPublicKey: fromBase64(r.signing_public_key),
|
|
|
|
|
signingPrivateKey: fromBase64(r.signing_private_key),
|
|
|
|
|
dhPublicKey: fromBase64(r.dh_public_key),
|
|
|
|
|
dhPrivateKey: fromBase64(r.dh_private_key),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveIdentityKeyPair(kp: IdentityKeyPair): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_identity (id, signing_public_key, signing_private_key, dh_public_key, dh_private_key)
|
|
|
|
|
VALUES (1, ${toBase64(kp.signingPublicKey)}, ${toBase64(kp.signingPrivateKey)}, ${toBase64(kp.dhPublicKey)}, ${toBase64(kp.dhPrivateKey)})
|
|
|
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
|
|
|
signing_public_key = EXCLUDED.signing_public_key,
|
|
|
|
|
signing_private_key = EXCLUDED.signing_private_key,
|
|
|
|
|
dh_public_key = EXCLUDED.dh_public_key,
|
|
|
|
|
dh_private_key = EXCLUDED.dh_private_key
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getLocalRegistrationId(): Promise<number> {
|
|
|
|
|
const rows = await this.sql<Array<{ value: string }>>`
|
|
|
|
|
SELECT value FROM shade_config WHERE key = 'registrationId'
|
|
|
|
|
`;
|
|
|
|
|
return rows.length ? parseInt(rows[0]!.value, 10) : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveLocalRegistrationId(id: number): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_config (key, value) VALUES ('registrationId', ${String(id)})
|
|
|
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Signed PreKeys ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getSignedPreKey(keyId: number): Promise<SignedPreKey | null> {
|
|
|
|
|
const rows = await this.sql<Array<{ data_json: string }>>`
|
|
|
|
|
SELECT data_json FROM shade_signed_prekeys WHERE key_id = ${keyId}
|
|
|
|
|
`;
|
|
|
|
|
return rows.length ? deserializeSignedPreKey(rows[0]!.data_json) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSignedPreKey(key: SignedPreKey): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_signed_prekeys (key_id, data_json)
|
|
|
|
|
VALUES (${key.keyId}, ${serializeSignedPreKey(key)})
|
|
|
|
|
ON CONFLICT (key_id) DO UPDATE SET data_json = EXCLUDED.data_json
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeSignedPreKey(keyId: number): Promise<void> {
|
|
|
|
|
await this.sql`DELETE FROM shade_signed_prekeys WHERE key_id = ${keyId}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── One-Time PreKeys ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null> {
|
|
|
|
|
const rows = await this.sql<Array<{ data_json: string }>>`
|
|
|
|
|
SELECT data_json FROM shade_one_time_prekeys WHERE key_id = ${keyId}
|
|
|
|
|
`;
|
|
|
|
|
return rows.length ? deserializeOneTimePreKey(rows[0]!.data_json) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveOneTimePreKey(key: OneTimePreKey): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_one_time_prekeys (key_id, data_json)
|
|
|
|
|
VALUES (${key.keyId}, ${serializeOneTimePreKey(key)})
|
|
|
|
|
ON CONFLICT (key_id) DO UPDATE SET data_json = EXCLUDED.data_json
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeOneTimePreKey(keyId: number): Promise<void> {
|
|
|
|
|
await this.sql`DELETE FROM shade_one_time_prekeys WHERE key_id = ${keyId}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getOneTimePreKeyCount(): Promise<number> {
|
|
|
|
|
const rows = await this.sql<Array<{ count: string }>>`
|
|
|
|
|
SELECT COUNT(*)::text as count FROM shade_one_time_prekeys
|
|
|
|
|
`;
|
|
|
|
|
return parseInt(rows[0]!.count, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Sessions ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getSession(address: string): Promise<SessionState | null> {
|
|
|
|
|
const rows = await this.sql<Array<{ state_json: string }>>`
|
|
|
|
|
SELECT state_json FROM shade_sessions WHERE address = ${address}
|
|
|
|
|
`;
|
|
|
|
|
return rows.length ? deserializeSessionState(rows[0]!.state_json) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSession(address: string, state: SessionState): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_sessions (address, state_json)
|
|
|
|
|
VALUES (${address}, ${serializeSessionState(state)})
|
|
|
|
|
ON CONFLICT (address) DO UPDATE SET state_json = EXCLUDED.state_json
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeSession(address: string): Promise<void> {
|
|
|
|
|
await this.sql`DELETE FROM shade_sessions WHERE address = ${address}`;
|
|
|
|
|
}
|
|
|
|
|
|
fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.
First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.
Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.
StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.
A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.
The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.
Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.
Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
|
|
|
// ─── Session label aliases ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getSessionAlias(alias: string): Promise<string | null> {
|
|
|
|
|
const rows = await this.sql<Array<{ canonical: string }>>`
|
|
|
|
|
SELECT canonical FROM shade_session_aliases WHERE alias = ${alias}
|
|
|
|
|
`;
|
|
|
|
|
return rows.length ? rows[0]!.canonical : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_session_aliases (alias, canonical)
|
|
|
|
|
VALUES (${alias}, ${canonical})
|
|
|
|
|
ON CONFLICT (alias) DO UPDATE SET canonical = EXCLUDED.canonical
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
|
|
|
|
await this.sql`DELETE FROM shade_session_aliases WHERE canonical = ${canonical}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:51:29 +02:00
|
|
|
// ─── Trust ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
|
|
|
|
const rows = await this.sql<Array<{ identity_key: string }>>`
|
|
|
|
|
SELECT identity_key FROM shade_trusted_identities WHERE address = ${address}
|
|
|
|
|
`;
|
|
|
|
|
if (rows.length === 0) return true; // TOFU
|
|
|
|
|
return constantTimeEqual(fromBase64(rows[0]!.identity_key), identityKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_trusted_identities (address, identity_key)
|
|
|
|
|
VALUES (${address}, ${toBase64(identityKey)})
|
|
|
|
|
ON CONFLICT (address) DO UPDATE SET identity_key = EXCLUDED.identity_key
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Identity History ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async addRetiredIdentity(identity: RetiredIdentity): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_retired_identities (data_json, retired_at)
|
|
|
|
|
VALUES (${serializeIdentityKeyPair(identity.keyPair)}, ${identity.retiredAt})
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getRetiredIdentities(): Promise<RetiredIdentity[]> {
|
|
|
|
|
const rows = await this.sql<Array<{ data_json: string; retired_at: string }>>`
|
|
|
|
|
SELECT data_json, retired_at FROM shade_retired_identities ORDER BY retired_at DESC
|
|
|
|
|
`;
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
keyPair: deserializeIdentityKeyPair(r.data_json),
|
|
|
|
|
retiredAt: Number(r.retired_at),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async pruneRetiredIdentities(olderThan: number): Promise<void> {
|
|
|
|
|
await this.sql`DELETE FROM shade_retired_identities WHERE retired_at < ${olderThan}`;
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
// ─── Stream-transfer resume state (v0.2.0) ────────────────
|
|
|
|
|
|
|
|
|
|
async saveStreamState(state: PersistedStreamState): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_stream_state (
|
|
|
|
|
stream_id, direction, peer_address, status,
|
|
|
|
|
metadata_json, partition_json, lane_state_json, io_descriptor_json,
|
|
|
|
|
secret_enc, secret_nonce, overall_hash_state, created_at, updated_at
|
|
|
|
|
) VALUES (
|
|
|
|
|
${state.streamId}, ${state.direction}, ${state.peerAddress}, ${state.status},
|
|
|
|
|
${state.metadataJson}, ${state.partitionJson}, ${state.laneStateJson}, ${state.ioDescriptorJson},
|
|
|
|
|
${state.secretEnc}, ${state.secretNonce}, ${state.overallHashState ?? null},
|
|
|
|
|
${state.createdAt}, ${state.updatedAt}
|
|
|
|
|
)
|
|
|
|
|
ON CONFLICT (stream_id) DO UPDATE SET
|
|
|
|
|
direction = EXCLUDED.direction,
|
|
|
|
|
peer_address = EXCLUDED.peer_address,
|
|
|
|
|
status = EXCLUDED.status,
|
|
|
|
|
metadata_json = EXCLUDED.metadata_json,
|
|
|
|
|
partition_json = EXCLUDED.partition_json,
|
|
|
|
|
lane_state_json = EXCLUDED.lane_state_json,
|
|
|
|
|
io_descriptor_json = EXCLUDED.io_descriptor_json,
|
|
|
|
|
secret_enc = EXCLUDED.secret_enc,
|
|
|
|
|
secret_nonce = EXCLUDED.secret_nonce,
|
|
|
|
|
overall_hash_state = EXCLUDED.overall_hash_state,
|
|
|
|
|
updated_at = EXCLUDED.updated_at
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getStreamState(streamId: string): Promise<PersistedStreamState | null> {
|
|
|
|
|
const rows = await this.sql<Array<Record<string, unknown>>>`
|
|
|
|
|
SELECT * FROM shade_stream_state WHERE stream_id = ${streamId}
|
|
|
|
|
`;
|
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
|
return rowToStreamState(rows[0]!);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeStreamState(streamId: string): Promise<void> {
|
|
|
|
|
await this.sql`DELETE FROM shade_stream_state WHERE stream_id = ${streamId}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listActiveStreamStates(direction?: 'send' | 'receive'): Promise<PersistedStreamState[]> {
|
|
|
|
|
const rows =
|
|
|
|
|
direction === undefined
|
|
|
|
|
? await this.sql<Array<Record<string, unknown>>>`
|
|
|
|
|
SELECT * FROM shade_stream_state
|
|
|
|
|
WHERE status IN ('active','paused')
|
|
|
|
|
ORDER BY updated_at DESC
|
|
|
|
|
`
|
|
|
|
|
: await this.sql<Array<Record<string, unknown>>>`
|
|
|
|
|
SELECT * FROM shade_stream_state
|
|
|
|
|
WHERE status IN ('active','paused') AND direction = ${direction}
|
|
|
|
|
ORDER BY updated_at DESC
|
|
|
|
|
`;
|
|
|
|
|
return rows.map(rowToStreamState);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async pruneStreamStates(olderThan: number): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
DELETE FROM shade_stream_state
|
|
|
|
|
WHERE status IN ('finished','aborted') AND updated_at < ${olderThan}
|
|
|
|
|
`;
|
|
|
|
|
}
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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>
2026-05-03 18:35:35 +02:00
|
|
|
|
|
|
|
|
// ─── Peer verifications (V3.3) ────────────────────────────
|
|
|
|
|
|
|
|
|
|
async savePeerVerification(v: PeerVerification): Promise<void> {
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_peer_verifications
|
|
|
|
|
(peer_address, fingerprint, verified_at, verified_by, identity_version)
|
|
|
|
|
VALUES (${v.peerAddress}, ${v.fingerprint}, ${v.verifiedAt}, ${v.verifiedBy}, ${v.identityVersion})
|
|
|
|
|
ON CONFLICT (peer_address) DO UPDATE SET
|
|
|
|
|
fingerprint = EXCLUDED.fingerprint,
|
|
|
|
|
verified_at = EXCLUDED.verified_at,
|
|
|
|
|
verified_by = EXCLUDED.verified_by,
|
|
|
|
|
identity_version = EXCLUDED.identity_version
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getPeerVerification(address: string): Promise<PeerVerification | null> {
|
|
|
|
|
const rows = await this.sql<Array<{ peer_address: string; fingerprint: string; verified_at: string; verified_by: string; identity_version: string }>>`
|
|
|
|
|
SELECT peer_address, fingerprint, verified_at, verified_by, identity_version
|
|
|
|
|
FROM shade_peer_verifications WHERE peer_address = ${address}
|
|
|
|
|
`;
|
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
|
const r = rows[0]!;
|
|
|
|
|
return {
|
|
|
|
|
peerAddress: r.peer_address,
|
|
|
|
|
fingerprint: r.fingerprint,
|
|
|
|
|
verifiedAt: Number(r.verified_at),
|
|
|
|
|
verifiedBy: r.verified_by as PeerVerificationSource,
|
|
|
|
|
identityVersion: Number(r.identity_version),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removePeerVerification(address: string): Promise<void> {
|
|
|
|
|
await this.sql`DELETE FROM shade_peer_verifications WHERE peer_address = ${address}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getPeerIdentityVersion(address: string): Promise<number> {
|
|
|
|
|
const rows = await this.sql<Array<{ version: string }>>`
|
|
|
|
|
SELECT version FROM shade_peer_identity_versions WHERE peer_address = ${address}
|
|
|
|
|
`;
|
|
|
|
|
return rows.length ? Number(rows[0]!.version) : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async bumpPeerIdentityVersion(address: string): Promise<number> {
|
|
|
|
|
const current = await this.getPeerIdentityVersion(address);
|
|
|
|
|
const next = current + 1;
|
|
|
|
|
await this.sql`
|
|
|
|
|
INSERT INTO shade_peer_identity_versions (peer_address, version)
|
|
|
|
|
VALUES (${address}, ${next})
|
|
|
|
|
ON CONFLICT (peer_address) DO UPDATE SET version = EXCLUDED.version
|
|
|
|
|
`;
|
|
|
|
|
return next;
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rowToStreamState(row: Record<string, unknown>): PersistedStreamState {
|
|
|
|
|
const out: PersistedStreamState = {
|
|
|
|
|
streamId: String(row.stream_id),
|
|
|
|
|
direction: row.direction as 'send' | 'receive',
|
|
|
|
|
peerAddress: String(row.peer_address),
|
|
|
|
|
status: row.status as 'active' | 'paused' | 'finished' | 'aborted',
|
|
|
|
|
metadataJson: String(row.metadata_json),
|
|
|
|
|
partitionJson: String(row.partition_json),
|
|
|
|
|
laneStateJson: String(row.lane_state_json),
|
|
|
|
|
ioDescriptorJson: String(row.io_descriptor_json),
|
|
|
|
|
secretEnc: row.secret_enc as Uint8Array,
|
|
|
|
|
secretNonce: row.secret_nonce as Uint8Array,
|
|
|
|
|
createdAt: Number(row.created_at),
|
|
|
|
|
updatedAt: Number(row.updated_at),
|
|
|
|
|
};
|
|
|
|
|
if (row.overall_hash_state !== null && row.overall_hash_state !== undefined) {
|
|
|
|
|
out.overallHashState = String(row.overall_hash_state);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
2026-04-10 17:51:29 +02:00
|
|
|
}
|