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

@@ -0,0 +1,228 @@
/**
* Row codec — bridges between the StorageProvider's typed values and the
* AEAD-sealed BLOB that lives in an `_enc` table.
*
* The codec is deliberately shared between SQLite and Postgres backends so
* the wire format (and AAD binding) is identical across them. A backup
* created with one backend can be re-keyed and restored under the other.
*/
import type {
IdentityKeyPair,
OneTimePreKey,
PersistedStreamState,
RetiredIdentity,
SessionState,
SignedPreKey,
} from '@shade/core';
import {
serializeIdentityKeyPair, deserializeIdentityKeyPair,
serializeOneTimePreKey, deserializeOneTimePreKey,
serializeSessionState, deserializeSessionState,
serializeSignedPreKey, deserializeSignedPreKey,
toBase64, fromBase64,
} from '@shade/core';
import { aeadOpen, aeadSeal } from './aead.js';
import { buildAad, deriveNonce } from './kdf.js';
import type { KeyManager } from './key-manager.js';
const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
/** Logical column identifiers — used for fieldKey + AAD binding. */
export const COL = {
identity: 'identity',
config: 'config',
signedPrekey: 'signed_prekey',
oneTimePrekey: 'one_time_prekey',
session: 'session',
trustedIdentity: 'trusted_identity',
retiredIdentity: 'retired_identity',
streamSensitive: 'stream_sensitive',
} as const;
/** Logical table identifiers — used for fieldKey + AAD binding. */
export const TBL = {
identity: 'identity',
config: 'config',
signedPrekeys: 'signed_prekeys',
oneTimePrekeys: 'one_time_prekeys',
sessions: 'sessions',
trustedIdentities: 'trusted_identities',
retiredIdentities: 'retired_identities',
streamState: 'stream_state',
} as const;
/** Encrypt an arbitrary string payload bound to (table, column, pk). */
export async function sealString(
km: KeyManager,
table: string,
column: string,
pk: string,
plaintext: string,
): Promise<Uint8Array> {
const key = km.fieldKey(table, column);
const nonce = deriveNonce(key, table, pk);
const aad = buildAad(table, column, pk);
return aeadSeal(key, nonce, TEXT_ENCODER.encode(plaintext), aad);
}
/** Decrypt a blob into a string, reconstructing AAD from (table, column, pk). */
export async function openString(
km: KeyManager,
table: string,
column: string,
pk: string,
blob: Uint8Array,
): Promise<string> {
const key = km.fieldKey(table, column);
const expectedNonce = deriveNonce(key, table, pk);
const aad = buildAad(table, column, pk);
const pt = await aeadOpen(key, blob, aad, expectedNonce);
return TEXT_DECODER.decode(pt);
}
/** Encrypt arbitrary bytes payload. */
export async function sealBytes(
km: KeyManager,
table: string,
column: string,
pk: string,
plaintext: Uint8Array,
): Promise<Uint8Array> {
const key = km.fieldKey(table, column);
const nonce = deriveNonce(key, table, pk);
const aad = buildAad(table, column, pk);
return aeadSeal(key, nonce, plaintext, aad);
}
/** Decrypt arbitrary bytes payload. */
export async function openBytes(
km: KeyManager,
table: string,
column: string,
pk: string,
blob: Uint8Array,
): Promise<Uint8Array> {
const key = km.fieldKey(table, column);
const expectedNonce = deriveNonce(key, table, pk);
const aad = buildAad(table, column, pk);
return aeadOpen(key, blob, aad, expectedNonce);
}
// ─── Typed encoders for each StorageProvider entity ──────────────────────
export async function sealIdentity(km: KeyManager, kp: IdentityKeyPair): Promise<Uint8Array> {
return sealString(km, TBL.identity, COL.identity, '1', serializeIdentityKeyPair(kp));
}
export async function openIdentity(km: KeyManager, blob: Uint8Array): Promise<IdentityKeyPair> {
return deserializeIdentityKeyPair(await openString(km, TBL.identity, COL.identity, '1', blob));
}
export async function sealConfig(km: KeyManager, key: string, value: string): Promise<Uint8Array> {
return sealString(km, TBL.config, COL.config, key, value);
}
export async function openConfig(km: KeyManager, key: string, blob: Uint8Array): Promise<string> {
return openString(km, TBL.config, COL.config, key, blob);
}
export async function sealSignedPreKey(km: KeyManager, k: SignedPreKey): Promise<Uint8Array> {
return sealString(km, TBL.signedPrekeys, COL.signedPrekey, String(k.keyId), serializeSignedPreKey(k));
}
export async function openSignedPreKey(km: KeyManager, keyId: number, blob: Uint8Array): Promise<SignedPreKey> {
return deserializeSignedPreKey(await openString(km, TBL.signedPrekeys, COL.signedPrekey, String(keyId), blob));
}
export async function sealOneTimePreKey(km: KeyManager, k: OneTimePreKey): Promise<Uint8Array> {
return sealString(km, TBL.oneTimePrekeys, COL.oneTimePrekey, String(k.keyId), serializeOneTimePreKey(k));
}
export async function openOneTimePreKey(km: KeyManager, keyId: number, blob: Uint8Array): Promise<OneTimePreKey> {
return deserializeOneTimePreKey(await openString(km, TBL.oneTimePrekeys, COL.oneTimePrekey, String(keyId), blob));
}
export async function sealSession(km: KeyManager, address: string, state: SessionState): Promise<Uint8Array> {
return sealString(km, TBL.sessions, COL.session, address, serializeSessionState(state));
}
export async function openSession(km: KeyManager, address: string, blob: Uint8Array): Promise<SessionState> {
return deserializeSessionState(await openString(km, TBL.sessions, COL.session, address, blob));
}
export async function sealTrust(km: KeyManager, address: string, identityKey: Uint8Array): Promise<Uint8Array> {
return sealString(km, TBL.trustedIdentities, COL.trustedIdentity, address, toBase64(identityKey));
}
export async function openTrust(km: KeyManager, address: string, blob: Uint8Array): Promise<Uint8Array> {
return fromBase64(await openString(km, TBL.trustedIdentities, COL.trustedIdentity, address, blob));
}
/**
* Retired identities are append-only with auto-incremented row IDs. We bind
* AAD on (retiredAt as string) since retired_at is a unique-enough natural
* key for this row; collisions are practically impossible (ms timestamp).
*/
export async function sealRetired(km: KeyManager, ri: RetiredIdentity): Promise<Uint8Array> {
const pk = String(ri.retiredAt);
return sealString(km, TBL.retiredIdentities, COL.retiredIdentity, pk, serializeIdentityKeyPair(ri.keyPair));
}
export async function openRetired(km: KeyManager, retiredAt: number, blob: Uint8Array): Promise<RetiredIdentity> {
const pk = String(retiredAt);
const json = await openString(km, TBL.retiredIdentities, COL.retiredIdentity, pk, blob);
return { keyPair: deserializeIdentityKeyPair(json), retiredAt };
}
/**
* Stream-state sensitive bundle. Plaintext fields (peer, status, dir,
* timestamps, streamId) stay in their own columns so the storage backend
* can run efficient queries; everything else lives in this encrypted blob.
*/
interface StreamSensitiveBundle {
metadataJson: string;
partitionJson: string;
laneStateJson: string;
ioDescriptorJson: string;
secretEnc: string; // base64
secretNonce: string; // base64
overallHashState?: string;
}
function packStreamSensitive(s: PersistedStreamState): StreamSensitiveBundle {
const out: StreamSensitiveBundle = {
metadataJson: s.metadataJson,
partitionJson: s.partitionJson,
laneStateJson: s.laneStateJson,
ioDescriptorJson: s.ioDescriptorJson,
secretEnc: toBase64(s.secretEnc),
secretNonce: toBase64(s.secretNonce),
};
if (s.overallHashState !== undefined) out.overallHashState = s.overallHashState;
return out;
}
export async function sealStreamSensitive(km: KeyManager, s: PersistedStreamState): Promise<Uint8Array> {
return sealString(km, TBL.streamState, COL.streamSensitive, s.streamId, JSON.stringify(packStreamSensitive(s)));
}
export async function openStreamSensitive(
km: KeyManager,
streamId: string,
blob: Uint8Array,
): Promise<Pick<PersistedStreamState, 'metadataJson' | 'partitionJson' | 'laneStateJson' | 'ioDescriptorJson' | 'secretEnc' | 'secretNonce' | 'overallHashState'>> {
const json = await openString(km, TBL.streamState, COL.streamSensitive, streamId, blob);
const b = JSON.parse(json) as StreamSensitiveBundle;
const out = {
metadataJson: b.metadataJson,
partitionJson: b.partitionJson,
laneStateJson: b.laneStateJson,
ioDescriptorJson: b.ioDescriptorJson,
secretEnc: fromBase64(b.secretEnc),
secretNonce: fromBase64(b.secretNonce),
} as Pick<PersistedStreamState, 'metadataJson' | 'partitionJson' | 'laneStateJson' | 'ioDescriptorJson' | 'secretEnc' | 'secretNonce' | 'overallHashState'>;
if (b.overallHashState !== undefined) (out as { overallHashState?: string }).overallHashState = b.overallHashState;
return out;
}