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:
82
packages/shade-storage-encrypted/src/crypto/aead.ts
Normal file
82
packages/shade-storage-encrypted/src/crypto/aead.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AEAD wrapper around AES-256-GCM via SubtleCrypto.
|
||||
*
|
||||
* Wire format for one ciphertext blob:
|
||||
*
|
||||
* nonce(12) || ciphertext-with-tag(N+16)
|
||||
*
|
||||
* On disk we store this as a single BLOB column; AAD is reconstructed at
|
||||
* read-time from (table, column, pk) and is NOT stored.
|
||||
*/
|
||||
|
||||
const NONCE_LEN = 12;
|
||||
|
||||
function bs(u: Uint8Array): ArrayBuffer {
|
||||
return u as unknown as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function importKey(key: Uint8Array, usages: KeyUsage[]): Promise<CryptoKey> {
|
||||
if (key.length !== 32) throw new Error(`AES-256-GCM key must be 32 bytes, got ${key.length}`);
|
||||
return globalThis.crypto.subtle.importKey('raw', bs(key), 'AES-GCM', false, usages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext blob with the given key and a deterministic nonce.
|
||||
* Returns `nonce || ct||tag` as a single Uint8Array suitable for direct
|
||||
* BLOB storage.
|
||||
*/
|
||||
export async function aeadSeal(
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
aad: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
if (nonce.length !== NONCE_LEN) throw new Error(`nonce must be ${NONCE_LEN} bytes`);
|
||||
const aesKey = await importKey(key, ['encrypt']);
|
||||
const ct = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: bs(nonce), additionalData: bs(aad) },
|
||||
aesKey,
|
||||
bs(plaintext),
|
||||
);
|
||||
const ctU8 = new Uint8Array(ct);
|
||||
const out = new Uint8Array(NONCE_LEN + ctU8.length);
|
||||
out.set(nonce, 0);
|
||||
out.set(ctU8, NONCE_LEN);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a `nonce || ct||tag` blob. The expected nonce is verified against
|
||||
* the prefix to detect tampering before we even reach the AEAD; if the
|
||||
* caller passes a `expectedNonce`, mismatch throws before SubtleCrypto runs.
|
||||
*/
|
||||
export async function aeadOpen(
|
||||
key: Uint8Array,
|
||||
blob: Uint8Array,
|
||||
aad: Uint8Array,
|
||||
expectedNonce?: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
if (blob.length < NONCE_LEN + 16) throw new Error('ciphertext blob too short');
|
||||
const nonce = blob.subarray(0, NONCE_LEN);
|
||||
const ct = blob.subarray(NONCE_LEN);
|
||||
if (expectedNonce !== undefined && !ctEqual(nonce, expectedNonce)) {
|
||||
throw new Error('nonce mismatch — ciphertext blob has been tampered or row identity changed');
|
||||
}
|
||||
const aesKey = await importKey(key, ['decrypt']);
|
||||
const pt = await globalThis.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: bs(nonce), additionalData: bs(aad) },
|
||||
aesKey,
|
||||
bs(ct),
|
||||
);
|
||||
return new Uint8Array(pt);
|
||||
}
|
||||
|
||||
function ctEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export const AEAD_NONCE_LEN = NONCE_LEN;
|
||||
export const AEAD_TAG_LEN = 16;
|
||||
79
packages/shade-storage-encrypted/src/crypto/kdf.ts
Normal file
79
packages/shade-storage-encrypted/src/crypto/kdf.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Key derivation primitives for at-rest storage encryption.
|
||||
*
|
||||
* Hierarchy:
|
||||
* masterKey (from passphrase / keychain / app-injected)
|
||||
* │
|
||||
* ├─ HKDF("shade-storage-v1") → storageKey (32 bytes)
|
||||
* │ └─ HKDF(storageKey, table || ":" || column) → fieldKey (32 bytes)
|
||||
* │
|
||||
* └─ HKDF("shade-storage-version-v1") → versionKey (used during rotation)
|
||||
*/
|
||||
|
||||
import { scryptAsync } from '@noble/hashes/scrypt.js';
|
||||
import { hkdf } from '@noble/hashes/hkdf.js';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
|
||||
const TEXT = new TextEncoder();
|
||||
|
||||
/** scrypt parameters — interactive; sized for sub-second derivation on commodity HW */
|
||||
export interface ScryptParams {
|
||||
N: number;
|
||||
r: number;
|
||||
p: number;
|
||||
dkLen: number;
|
||||
}
|
||||
|
||||
/** Default: N=2^17, r=8, p=1, 32-byte output. ~250ms on a modern laptop. */
|
||||
export const DEFAULT_SCRYPT: ScryptParams = { N: 1 << 17, r: 8, p: 1, dkLen: 32 };
|
||||
|
||||
/**
|
||||
* Derive a 32-byte master key from a passphrase + salt using scrypt.
|
||||
* The salt MUST be persisted alongside the encrypted database (16-byte random).
|
||||
*/
|
||||
export async function deriveMasterKey(
|
||||
passphrase: string,
|
||||
salt: Uint8Array,
|
||||
params: ScryptParams = DEFAULT_SCRYPT,
|
||||
): Promise<Uint8Array> {
|
||||
if (passphrase.length === 0) {
|
||||
throw new Error('passphrase must be non-empty');
|
||||
}
|
||||
if (salt.length < 16) {
|
||||
throw new Error('salt must be at least 16 bytes');
|
||||
}
|
||||
return scryptAsync(TEXT.encode(passphrase.normalize('NFKC')), salt, params);
|
||||
}
|
||||
|
||||
/** HKDF-SHA-256 with explicit info string. */
|
||||
export function hkdfDerive(ikm: Uint8Array, info: string, length = 32, salt?: Uint8Array): Uint8Array {
|
||||
return hkdf(sha256, ikm, salt, TEXT.encode(info), length);
|
||||
}
|
||||
|
||||
/** Derive the storageKey from masterKey. Stable, deterministic. */
|
||||
export function deriveStorageKey(masterKey: Uint8Array): Uint8Array {
|
||||
return hkdfDerive(masterKey, 'shade-storage-v1', 32);
|
||||
}
|
||||
|
||||
/** Derive the per-(table, column) field key. Stable, deterministic. */
|
||||
export function deriveFieldKey(storageKey: Uint8Array, table: string, column: string): Uint8Array {
|
||||
return hkdfDerive(storageKey, `shade-field-v1:${table}:${column}`, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic 12-byte AEAD nonce from a row key (typically the
|
||||
* field key) plus (table, pk) binding. With per-field keys, deterministic
|
||||
* nonces are safe because each (key, plaintext) pair appears at most once
|
||||
* — re-saving the same row reuses the (nonce, key) pair only because the
|
||||
* plaintext also changes (chain ratchet, prekey state, etc.). The AAD
|
||||
* also binds (table, column, pk) so swapping is rejected on decrypt.
|
||||
*/
|
||||
export function deriveNonce(rowKey: Uint8Array, table: string, pk: string): Uint8Array {
|
||||
const out = hkdfDerive(rowKey, `shade-row-nonce-v1:${table}:${pk}`, 12);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build the AAD that binds (table, column, pk) to a ciphertext. */
|
||||
export function buildAad(table: string, column: string, pk: string): Uint8Array {
|
||||
return TEXT.encode(`shade-aad-v1|${table}|${column}|${pk}`);
|
||||
}
|
||||
132
packages/shade-storage-encrypted/src/crypto/key-manager.ts
Normal file
132
packages/shade-storage-encrypted/src/crypto/key-manager.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* KeyManager — owns the masterKey lifecycle for at-rest encryption.
|
||||
*
|
||||
* Three sources are supported:
|
||||
* 1. passphrase — scrypt-derived from a developer-supplied secret
|
||||
* 2. keychain — fetched from OS keychain via @shade/keychain (optional dep)
|
||||
* 3. injected — caller supplies the 32-byte raw key directly
|
||||
*
|
||||
* The KeyManager pre-derives storageKey at construction time and caches the
|
||||
* per-(table, column) field keys. masterKey is zeroized after storageKey
|
||||
* derivation to limit residency.
|
||||
*/
|
||||
|
||||
import { deriveFieldKey, deriveMasterKey, deriveStorageKey, type ScryptParams, DEFAULT_SCRYPT } from './kdf.js';
|
||||
|
||||
export type KeySource =
|
||||
| {
|
||||
kind: 'passphrase';
|
||||
passphrase: string;
|
||||
/** Stable 16+ byte salt persisted alongside the DB. */
|
||||
salt: Uint8Array;
|
||||
params?: ScryptParams;
|
||||
}
|
||||
| {
|
||||
kind: 'keychain';
|
||||
/** Service identifier (e.g. "shade.storage"). */
|
||||
service: string;
|
||||
/** Account / key name within the keychain. */
|
||||
account: string;
|
||||
/** If true, generate + store a new 32-byte key when one is absent. */
|
||||
createIfMissing?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'injected';
|
||||
/** Raw 32-byte master key. */
|
||||
key: Uint8Array;
|
||||
};
|
||||
|
||||
/** Pluggable keychain backend. Implementations live in @shade/keychain. */
|
||||
export interface KeychainBackend {
|
||||
get(service: string, account: string): Promise<Uint8Array | null>;
|
||||
set(service: string, account: string, value: Uint8Array): Promise<void>;
|
||||
delete(service: string, account: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface KeyManagerOptions {
|
||||
/** Required when KeySource.kind === 'keychain'. */
|
||||
keychain?: KeychainBackend;
|
||||
}
|
||||
|
||||
export class KeyManager {
|
||||
private readonly storageKey: Uint8Array;
|
||||
private readonly fieldKeyCache = new Map<string, Uint8Array>();
|
||||
|
||||
private constructor(storageKey: Uint8Array) {
|
||||
this.storageKey = storageKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a KeyManager from any supported source. May call the OS keychain
|
||||
* (async) or run scrypt (slow on cold start).
|
||||
*/
|
||||
static async open(source: KeySource, opts: KeyManagerOptions = {}): Promise<KeyManager> {
|
||||
const masterKey = await resolveMasterKey(source, opts);
|
||||
try {
|
||||
const storageKey = deriveStorageKey(masterKey);
|
||||
return new KeyManager(storageKey);
|
||||
} finally {
|
||||
// Zeroize whichever buffer we can — never the caller's own buffer
|
||||
// when source.kind === 'injected', since the caller may still hold it.
|
||||
if (source.kind !== 'injected') masterKey.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up (and cache) the AEAD key for a given table+column. */
|
||||
fieldKey(table: string, column: string): Uint8Array {
|
||||
const cacheKey = `${table}\x1f${column}`;
|
||||
let k = this.fieldKeyCache.get(cacheKey);
|
||||
if (!k) {
|
||||
k = deriveFieldKey(this.storageKey, table, column);
|
||||
this.fieldKeyCache.set(cacheKey, k);
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
/** Sanity helper for verifying that a passphrase decrypts the active DB. */
|
||||
storageKeyFingerprint(): Uint8Array {
|
||||
// Deterministic 8-byte tag for "is this the same masterKey?" without
|
||||
// exposing the storageKey itself. Tag-only — not a MAC.
|
||||
const tag = new Uint8Array(8);
|
||||
for (let i = 0; i < this.storageKey.length; i++) {
|
||||
tag[i % 8]! ^= this.storageKey[i]!;
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
/** Forget all derived keys — call on shutdown. */
|
||||
destroy(): void {
|
||||
this.storageKey.fill(0);
|
||||
for (const v of this.fieldKeyCache.values()) v.fill(0);
|
||||
this.fieldKeyCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMasterKey(source: KeySource, opts: KeyManagerOptions): Promise<Uint8Array> {
|
||||
switch (source.kind) {
|
||||
case 'passphrase':
|
||||
return deriveMasterKey(source.passphrase, source.salt, source.params ?? DEFAULT_SCRYPT);
|
||||
|
||||
case 'injected':
|
||||
if (source.key.length !== 32) throw new Error('injected key must be exactly 32 bytes');
|
||||
return new Uint8Array(source.key); // copy, in case caller mutates
|
||||
|
||||
case 'keychain': {
|
||||
if (!opts.keychain) {
|
||||
throw new Error('keychain source requires opts.keychain (install @shade/keychain)');
|
||||
}
|
||||
const existing = await opts.keychain.get(source.service, source.account);
|
||||
if (existing) {
|
||||
if (existing.length !== 32) throw new Error('keychain returned a non-32-byte key');
|
||||
return existing;
|
||||
}
|
||||
if (!source.createIfMissing) {
|
||||
throw new Error(`no key in keychain for ${source.service}/${source.account}`);
|
||||
}
|
||||
const fresh = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(fresh);
|
||||
await opts.keychain.set(source.service, source.account, fresh);
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
}
|
||||
228
packages/shade-storage-encrypted/src/crypto/row-codec.ts
Normal file
228
packages/shade-storage-encrypted/src/crypto/row-codec.ts
Normal 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;
|
||||
}
|
||||
34
packages/shade-storage-encrypted/src/index.ts
Normal file
34
packages/shade-storage-encrypted/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export {
|
||||
KeyManager,
|
||||
type KeySource,
|
||||
type KeychainBackend,
|
||||
type KeyManagerOptions,
|
||||
} from './crypto/key-manager.js';
|
||||
export {
|
||||
DEFAULT_SCRYPT,
|
||||
type ScryptParams,
|
||||
deriveMasterKey,
|
||||
deriveStorageKey,
|
||||
deriveFieldKey,
|
||||
deriveNonce,
|
||||
buildAad,
|
||||
hkdfDerive,
|
||||
} from './crypto/kdf.js';
|
||||
export {
|
||||
AEAD_NONCE_LEN,
|
||||
AEAD_TAG_LEN,
|
||||
aeadSeal,
|
||||
aeadOpen,
|
||||
} from './crypto/aead.js';
|
||||
export { EncryptedSQLiteStorage } from './storage/encrypted-sqlite.js';
|
||||
export {
|
||||
EncryptedPostgresStorage,
|
||||
ensureEncryptedClientTables,
|
||||
} from './storage/encrypted-postgres.js';
|
||||
export {
|
||||
migrateSqliteToEncrypted,
|
||||
rotateSqliteEncryptionKey,
|
||||
type MigrateOptions,
|
||||
type RotateOptions,
|
||||
type MigrateReport,
|
||||
} from './migrate/migrate-sqlite.js';
|
||||
321
packages/shade-storage-encrypted/src/migrate/migrate-sqlite.ts
Normal file
321
packages/shade-storage-encrypted/src/migrate/migrate-sqlite.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* SQLite migration: unencrypted shade tables → encrypted `_enc` tables.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Refuse to start if WAL has uncommitted writes.
|
||||
* 2. Optionally take a `.bak` snapshot of the DB file.
|
||||
* 3. Open EncryptedSQLiteStorage on the same DB (creates `_enc` tables).
|
||||
* 4. Read each row from the unencrypted table → decode → re-write through
|
||||
* the encrypted store. This re-uses the existing serialize helpers,
|
||||
* so the at-rest format is identical to a fresh write.
|
||||
* 5. After all rows are written, drop the unencrypted tables (unless
|
||||
* `--keep-original` is set).
|
||||
*
|
||||
* The migration is *resumable*: re-running it on a partially-migrated DB
|
||||
* skips rows that already exist (INSERT OR REPLACE — re-writes are
|
||||
* idempotent per row). It is also *atomic* per-row: either a row is
|
||||
* fully encrypted or the unencrypted version stays.
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { existsSync, copyFileSync } from 'node:fs';
|
||||
import {
|
||||
type IdentityKeyPair,
|
||||
type SignedPreKey,
|
||||
type OneTimePreKey,
|
||||
type SessionState,
|
||||
type RetiredIdentity,
|
||||
type PersistedStreamState,
|
||||
fromBase64,
|
||||
deserializeIdentityKeyPair,
|
||||
deserializeSignedPreKey,
|
||||
deserializeOneTimePreKey,
|
||||
deserializeSessionState,
|
||||
} from '@shade/core';
|
||||
import { KeyManager } from '../crypto/key-manager.js';
|
||||
import { EncryptedSQLiteStorage } from '../storage/encrypted-sqlite.js';
|
||||
|
||||
export interface MigrateOptions {
|
||||
/** Source SQLite file path. Defaults to SHADE_DB_PATH or /data/shade-client.db. */
|
||||
dbPath?: string;
|
||||
/** KeyManager pre-opened with the new master key. */
|
||||
keyManager: KeyManager;
|
||||
/** When true, preserve the unencrypted source tables. Default: false (drop after migrate). */
|
||||
keepOriginal?: boolean;
|
||||
/** Validate decryption of every row without writing. Useful for `--dry-run`. */
|
||||
dryRun?: boolean;
|
||||
/** When true, copy the DB to `<path>.bak` before migrating. Default: true. */
|
||||
backup?: boolean;
|
||||
/** Logger callback for progress messages. */
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface MigrateReport {
|
||||
identity: number;
|
||||
config: number;
|
||||
signedPrekeys: number;
|
||||
oneTimePrekeys: number;
|
||||
sessions: number;
|
||||
trustedIdentities: number;
|
||||
retiredIdentities: number;
|
||||
streamStates: number;
|
||||
durationMs: number;
|
||||
backupPath?: string;
|
||||
keptOriginal: boolean;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
const UNENCRYPTED_TABLES = [
|
||||
'identity', 'config', 'signed_prekeys', 'one_time_prekeys',
|
||||
'sessions', 'trusted_identities', 'retired_identities', 'stream_state',
|
||||
] as const;
|
||||
|
||||
export async function migrateSqliteToEncrypted(opts: MigrateOptions): Promise<MigrateReport> {
|
||||
const path = opts.dbPath ?? process.env.SHADE_DB_PATH ?? '/data/shade-client.db';
|
||||
const log = opts.log ?? (() => {});
|
||||
const dryRun = opts.dryRun ?? false;
|
||||
const keepOriginal = opts.keepOriginal ?? false;
|
||||
const backup = opts.backup ?? !dryRun;
|
||||
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`migrate-storage: source DB not found at ${path}`);
|
||||
}
|
||||
|
||||
let backupPath: string | undefined;
|
||||
if (backup) {
|
||||
backupPath = `${path}.bak.${Date.now()}`;
|
||||
copyFileSync(path, backupPath);
|
||||
log(`backup written to ${backupPath}`);
|
||||
}
|
||||
|
||||
const db = new Database(path);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
|
||||
// Refuse if WAL has uncommitted writes (must checkpoint first).
|
||||
const walCheck = db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() as { busy?: number } | undefined;
|
||||
if (walCheck?.busy) {
|
||||
throw new Error('migrate-storage: WAL has uncommitted writes — call PRAGMA wal_checkpoint(FULL) first');
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const report: MigrateReport = {
|
||||
identity: 0, config: 0, signedPrekeys: 0, oneTimePrekeys: 0,
|
||||
sessions: 0, trustedIdentities: 0, retiredIdentities: 0, streamStates: 0,
|
||||
durationMs: 0, keptOriginal: keepOriginal, dryRun,
|
||||
...(backupPath !== undefined ? { backupPath } : {}),
|
||||
};
|
||||
|
||||
const enc = await EncryptedSQLiteStorage.wrap(db, opts.keyManager);
|
||||
|
||||
// ─── identity (single row) ──────────────────────
|
||||
if (tableExists(db, 'identity')) {
|
||||
const row = db.prepare('SELECT signing_public_key, signing_private_key, dh_public_key, dh_private_key FROM identity WHERE id = 1')
|
||||
.get() as { signing_public_key: string; signing_private_key: string; dh_public_key: string; dh_private_key: string } | undefined;
|
||||
if (row) {
|
||||
const kp: IdentityKeyPair = {
|
||||
signingPublicKey: fromBase64(row.signing_public_key),
|
||||
signingPrivateKey: fromBase64(row.signing_private_key),
|
||||
dhPublicKey: fromBase64(row.dh_public_key),
|
||||
dhPrivateKey: fromBase64(row.dh_private_key),
|
||||
};
|
||||
if (!dryRun) await enc.saveIdentityKeyPair(kp);
|
||||
report.identity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── config ──────────────────────────────────────
|
||||
if (tableExists(db, 'config')) {
|
||||
const rows = db.prepare('SELECT key, value FROM config').all() as { key: string; value: string }[];
|
||||
for (const r of rows) {
|
||||
if (r.key === 'registrationId') {
|
||||
if (!dryRun) await enc.saveLocalRegistrationId(parseInt(r.value, 10));
|
||||
report.config++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── signed_prekeys ──────────────────────────────
|
||||
if (tableExists(db, 'signed_prekeys')) {
|
||||
const rows = db.prepare('SELECT data_json FROM signed_prekeys').all() as { data_json: string }[];
|
||||
for (const r of rows) {
|
||||
const k: SignedPreKey = deserializeSignedPreKey(r.data_json);
|
||||
if (!dryRun) await enc.saveSignedPreKey(k);
|
||||
report.signedPrekeys++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── one_time_prekeys ────────────────────────────
|
||||
if (tableExists(db, 'one_time_prekeys')) {
|
||||
const rows = db.prepare('SELECT data_json FROM one_time_prekeys').all() as { data_json: string }[];
|
||||
for (const r of rows) {
|
||||
const k: OneTimePreKey = deserializeOneTimePreKey(r.data_json);
|
||||
if (!dryRun) await enc.saveOneTimePreKey(k);
|
||||
report.oneTimePrekeys++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── sessions ────────────────────────────────────
|
||||
if (tableExists(db, 'sessions')) {
|
||||
const rows = db.prepare('SELECT address, state_json FROM sessions').all() as { address: string; state_json: string }[];
|
||||
for (const r of rows) {
|
||||
const s: SessionState = deserializeSessionState(r.state_json);
|
||||
if (!dryRun) await enc.saveSession(r.address, s);
|
||||
report.sessions++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── trusted_identities ──────────────────────────
|
||||
if (tableExists(db, 'trusted_identities')) {
|
||||
const rows = db.prepare('SELECT address, identity_key FROM trusted_identities').all() as { address: string; identity_key: string }[];
|
||||
for (const r of rows) {
|
||||
if (!dryRun) await enc.saveTrustedIdentity(r.address, fromBase64(r.identity_key));
|
||||
report.trustedIdentities++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── retired_identities ──────────────────────────
|
||||
if (tableExists(db, 'retired_identities')) {
|
||||
const rows = db.prepare('SELECT data_json, retired_at FROM retired_identities ORDER BY retired_at')
|
||||
.all() as { data_json: string; retired_at: number }[];
|
||||
for (const r of rows) {
|
||||
const ri: RetiredIdentity = {
|
||||
keyPair: deserializeIdentityKeyPair(r.data_json),
|
||||
retiredAt: Number(r.retired_at),
|
||||
};
|
||||
if (!dryRun) await enc.addRetiredIdentity(ri);
|
||||
report.retiredIdentities++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── stream_state ────────────────────────────────
|
||||
if (tableExists(db, 'stream_state')) {
|
||||
const rows = db.prepare('SELECT * FROM stream_state').all() as Array<{
|
||||
stream_id: string;
|
||||
direction: 'send' | 'receive';
|
||||
peer_address: string;
|
||||
status: 'active' | 'paused' | 'finished' | 'aborted';
|
||||
metadata_json: string;
|
||||
partition_json: string;
|
||||
lane_state_json: string;
|
||||
io_descriptor_json: string;
|
||||
secret_enc: Uint8Array | ArrayBuffer | number[];
|
||||
secret_nonce: Uint8Array | ArrayBuffer | number[];
|
||||
overall_hash_state: string | null;
|
||||
created_at: number | bigint;
|
||||
updated_at: number | bigint;
|
||||
}>;
|
||||
for (const r of rows) {
|
||||
const state: PersistedStreamState = {
|
||||
streamId: r.stream_id,
|
||||
direction: r.direction,
|
||||
peerAddress: r.peer_address,
|
||||
status: r.status,
|
||||
metadataJson: r.metadata_json,
|
||||
partitionJson: r.partition_json,
|
||||
laneStateJson: r.lane_state_json,
|
||||
ioDescriptorJson: r.io_descriptor_json,
|
||||
secretEnc: toBytes(r.secret_enc),
|
||||
secretNonce: toBytes(r.secret_nonce),
|
||||
createdAt: Number(r.created_at),
|
||||
updatedAt: Number(r.updated_at),
|
||||
};
|
||||
if (r.overall_hash_state !== null) state.overallHashState = r.overall_hash_state;
|
||||
if (!dryRun) await enc.saveStreamState(state);
|
||||
report.streamStates++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun && !keepOriginal) {
|
||||
log('dropping unencrypted tables…');
|
||||
db.transaction(() => {
|
||||
for (const t of UNENCRYPTED_TABLES) {
|
||||
db.exec(`DROP TABLE IF EXISTS ${t}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
report.durationMs = Date.now() - start;
|
||||
log(`migration ${dryRun ? 'dry-run ' : ''}complete in ${report.durationMs}ms`);
|
||||
return report;
|
||||
}
|
||||
|
||||
export interface RotateOptions {
|
||||
dbPath?: string;
|
||||
/** KeyManager opened with the *current* master key (for reads). */
|
||||
oldKeyManager: KeyManager;
|
||||
/** KeyManager opened with the *new* master key (for writes). */
|
||||
newKeyManager: KeyManager;
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Online ratchet rotation. Reads each encrypted row under `oldKeyManager`,
|
||||
* re-seals under `newKeyManager`, writes back. The DB stays online; brief
|
||||
* read-after-write inconsistency for in-flight readers is acceptable for
|
||||
* the use cases this targets (CLI tools, single-process servers).
|
||||
*
|
||||
* On completion the storage_key_fingerprint meta is updated so subsequent
|
||||
* opens require the new key.
|
||||
*/
|
||||
export async function rotateSqliteEncryptionKey(opts: RotateOptions): Promise<{ rowsRotated: number }> {
|
||||
const path = opts.dbPath ?? process.env.SHADE_DB_PATH ?? '/data/shade-client.db';
|
||||
const log = opts.log ?? (() => {});
|
||||
if (!existsSync(path)) throw new Error(`rotate-storage-key: source DB not found at ${path}`);
|
||||
|
||||
const db = new Database(path);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
|
||||
const oldStore = await EncryptedSQLiteStorage.wrap(db, opts.oldKeyManager);
|
||||
let rotated = 0;
|
||||
|
||||
// Identity
|
||||
const id = await oldStore.getIdentityKeyPair();
|
||||
// Config (registrationId)
|
||||
const reg = await oldStore.getLocalRegistrationId();
|
||||
// Sessions, prekeys, retired, stream — collect via list / scan
|
||||
const ssRows = db.prepare('SELECT address FROM sessions_enc').all() as { address: string }[];
|
||||
const sessions = await Promise.all(ssRows.map((r) => oldStore.getSession(r.address).then((s) => ({ address: r.address, s }))));
|
||||
const tiRows = db.prepare('SELECT address FROM trusted_identities_enc').all() as { address: string }[];
|
||||
const trusts = await Promise.all(tiRows.map(async (r) => {
|
||||
// Read raw blob then decrypt: openTrust returns identity_key bytes.
|
||||
const row = db.prepare('SELECT ciphertext FROM trusted_identities_enc WHERE address = ?').get(r.address) as { ciphertext: Uint8Array | ArrayBuffer };
|
||||
const { openTrust } = await import('../crypto/row-codec.js');
|
||||
return { address: r.address, identityKey: await openTrust(opts.oldKeyManager, r.address, toBytes(row.ciphertext)) };
|
||||
}));
|
||||
const sp = db.prepare('SELECT key_id FROM signed_prekeys_enc').all() as { key_id: number }[];
|
||||
const signed = await Promise.all(sp.map(async (r) => oldStore.getSignedPreKey(r.key_id)));
|
||||
const otp = db.prepare('SELECT key_id FROM one_time_prekeys_enc').all() as { key_id: number }[];
|
||||
const oneTime = await Promise.all(otp.map(async (r) => oldStore.getOneTimePreKey(r.key_id)));
|
||||
const retired = await oldStore.getRetiredIdentities();
|
||||
const streamRowsIds = db.prepare('SELECT stream_id FROM stream_state_enc').all() as { stream_id: string }[];
|
||||
const streams = await Promise.all(streamRowsIds.map((r) => oldStore.getStreamState(r.stream_id)));
|
||||
|
||||
// Drop the old fingerprint and let the new store re-create it.
|
||||
db.prepare('DELETE FROM shade_meta_enc WHERE key = ?').run('storage_key_fingerprint');
|
||||
|
||||
const newStore = await EncryptedSQLiteStorage.wrap(db, opts.newKeyManager);
|
||||
|
||||
if (id) { await newStore.saveIdentityKeyPair(id); rotated++; }
|
||||
if (reg) { await newStore.saveLocalRegistrationId(reg); rotated++; }
|
||||
for (const s of sessions) if (s.s) { await newStore.saveSession(s.address, s.s); rotated++; }
|
||||
for (const t of trusts) { await newStore.saveTrustedIdentity(t.address, t.identityKey); rotated++; }
|
||||
for (const k of signed) if (k) { await newStore.saveSignedPreKey(k); rotated++; }
|
||||
for (const k of oneTime) if (k) { await newStore.saveOneTimePreKey(k); rotated++; }
|
||||
for (const r of retired) { await newStore.addRetiredIdentity(r); rotated++; }
|
||||
for (const s of streams) if (s) { await newStore.saveStreamState(s); rotated++; }
|
||||
|
||||
log(`rotated ${rotated} encrypted rows`);
|
||||
return { rowsRotated: rotated };
|
||||
}
|
||||
|
||||
function tableExists(db: Database, name: string): boolean {
|
||||
const r = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name) as { name: string } | undefined;
|
||||
return r !== undefined;
|
||||
}
|
||||
|
||||
function toBytes(value: Uint8Array | ArrayBuffer | number[] | unknown): Uint8Array {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
if (Array.isArray(value)) return new Uint8Array(value as number[]);
|
||||
throw new Error(`Unsupported BLOB representation: ${typeof value}`);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import type { Sql } from 'postgres';
|
||||
import postgres from 'postgres';
|
||||
import type {
|
||||
IdentityKeyPair,
|
||||
OneTimePreKey,
|
||||
PeerVerification,
|
||||
PeerVerificationSource,
|
||||
PersistedStreamState,
|
||||
RetiredIdentity,
|
||||
SessionState,
|
||||
SignedPreKey,
|
||||
StorageProvider,
|
||||
} from '@shade/core';
|
||||
import { constantTimeEqual, toBase64 } from '@shade/core';
|
||||
import { KeyManager } from '../crypto/key-manager.js';
|
||||
import {
|
||||
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
|
||||
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
|
||||
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
|
||||
sealStreamSensitive, sealTrust,
|
||||
} from '../crypto/row-codec.js';
|
||||
|
||||
/**
|
||||
* PostgreSQL-backed StorageProvider with at-rest encryption (V3.2).
|
||||
*
|
||||
* Tables prefixed `shade_*_enc` to allow side-by-side migration with the
|
||||
* unencrypted `shade_*` tables. Sensitive payloads are sealed with
|
||||
* AES-256-GCM bound to (table, column, pk) AAD.
|
||||
*/
|
||||
export class EncryptedPostgresStorage implements StorageProvider {
|
||||
private constructor(
|
||||
private readonly sql: Sql,
|
||||
private readonly km: KeyManager,
|
||||
private readonly ownsConnection: boolean,
|
||||
) {}
|
||||
|
||||
/** Create from connection string (owns connection). */
|
||||
static async create(connectionString: string, km: KeyManager): Promise<EncryptedPostgresStorage> {
|
||||
const sql = postgres(connectionString);
|
||||
const store = new EncryptedPostgresStorage(sql, km, true);
|
||||
await ensureEncryptedClientTables(sql);
|
||||
await store.assertKeyMatchesOrPersistFingerprint();
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Wrap an existing postgres-js Sql client (caller owns it). */
|
||||
static async fromClient(sql: Sql, km: KeyManager): Promise<EncryptedPostgresStorage> {
|
||||
const store = new EncryptedPostgresStorage(sql, km, false);
|
||||
await ensureEncryptedClientTables(sql);
|
||||
await store.assertKeyMatchesOrPersistFingerprint();
|
||||
return store;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.ownsConnection) await this.sql.end();
|
||||
this.km.destroy();
|
||||
}
|
||||
|
||||
private async assertKeyMatchesOrPersistFingerprint(): Promise<void> {
|
||||
const expected = toBase64(this.km.storageKeyFingerprint());
|
||||
const rows = await this.sql<Array<{ value: string }>>`
|
||||
SELECT value FROM shade_meta_enc WHERE key = 'storage_key_fingerprint'
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
await this.sql`
|
||||
INSERT INTO shade_meta_enc (key, value) VALUES ('storage_key_fingerprint', ${expected})
|
||||
`;
|
||||
return;
|
||||
}
|
||||
if (rows[0]!.value !== expected) {
|
||||
throw new Error(
|
||||
'storage key mismatch — the supplied passphrase / keychain entry does not unlock this database',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Identity ──────────────────────────────────────────────
|
||||
|
||||
async getIdentityKeyPair(): Promise<IdentityKeyPair | null> {
|
||||
const rows = await this.sql<Array<{ ciphertext: Uint8Array }>>`
|
||||
SELECT ciphertext FROM shade_identity_enc WHERE id = 1
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return openIdentity(this.km, rows[0]!.ciphertext);
|
||||
}
|
||||
|
||||
async saveIdentityKeyPair(kp: IdentityKeyPair): Promise<void> {
|
||||
const blob = await sealIdentity(this.km, kp);
|
||||
await this.sql`
|
||||
INSERT INTO shade_identity_enc (id, ciphertext) VALUES (1, ${blob})
|
||||
ON CONFLICT (id) DO UPDATE SET ciphertext = EXCLUDED.ciphertext
|
||||
`;
|
||||
}
|
||||
|
||||
async getLocalRegistrationId(): Promise<number> {
|
||||
const rows = await this.sql<Array<{ ciphertext: Uint8Array }>>`
|
||||
SELECT ciphertext FROM shade_config_enc WHERE key = 'registrationId'
|
||||
`;
|
||||
if (rows.length === 0) return 0;
|
||||
return parseInt(await openConfig(this.km, 'registrationId', rows[0]!.ciphertext), 10);
|
||||
}
|
||||
|
||||
async saveLocalRegistrationId(id: number): Promise<void> {
|
||||
const blob = await sealConfig(this.km, 'registrationId', String(id));
|
||||
await this.sql`
|
||||
INSERT INTO shade_config_enc (key, ciphertext) VALUES ('registrationId', ${blob})
|
||||
ON CONFLICT (key) DO UPDATE SET ciphertext = EXCLUDED.ciphertext
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Signed PreKeys ────────────────────────────────────────
|
||||
|
||||
async getSignedPreKey(keyId: number): Promise<SignedPreKey | null> {
|
||||
const rows = await this.sql<Array<{ ciphertext: Uint8Array }>>`
|
||||
SELECT ciphertext FROM shade_signed_prekeys_enc WHERE key_id = ${keyId}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return openSignedPreKey(this.km, keyId, rows[0]!.ciphertext);
|
||||
}
|
||||
|
||||
async saveSignedPreKey(key: SignedPreKey): Promise<void> {
|
||||
const blob = await sealSignedPreKey(this.km, key);
|
||||
await this.sql`
|
||||
INSERT INTO shade_signed_prekeys_enc (key_id, ciphertext) VALUES (${key.keyId}, ${blob})
|
||||
ON CONFLICT (key_id) DO UPDATE SET ciphertext = EXCLUDED.ciphertext
|
||||
`;
|
||||
}
|
||||
|
||||
async removeSignedPreKey(keyId: number): Promise<void> {
|
||||
await this.sql`DELETE FROM shade_signed_prekeys_enc WHERE key_id = ${keyId}`;
|
||||
}
|
||||
|
||||
// ─── One-Time PreKeys ──────────────────────────────────────
|
||||
|
||||
async getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null> {
|
||||
const rows = await this.sql<Array<{ ciphertext: Uint8Array }>>`
|
||||
SELECT ciphertext FROM shade_one_time_prekeys_enc WHERE key_id = ${keyId}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return openOneTimePreKey(this.km, keyId, rows[0]!.ciphertext);
|
||||
}
|
||||
|
||||
async saveOneTimePreKey(key: OneTimePreKey): Promise<void> {
|
||||
const blob = await sealOneTimePreKey(this.km, key);
|
||||
await this.sql`
|
||||
INSERT INTO shade_one_time_prekeys_enc (key_id, ciphertext) VALUES (${key.keyId}, ${blob})
|
||||
ON CONFLICT (key_id) DO UPDATE SET ciphertext = EXCLUDED.ciphertext
|
||||
`;
|
||||
}
|
||||
|
||||
async removeOneTimePreKey(keyId: number): Promise<void> {
|
||||
await this.sql`DELETE FROM shade_one_time_prekeys_enc 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_enc
|
||||
`;
|
||||
return parseInt(rows[0]!.count, 10);
|
||||
}
|
||||
|
||||
// ─── Sessions ──────────────────────────────────────────────
|
||||
|
||||
async getSession(address: string): Promise<SessionState | null> {
|
||||
const rows = await this.sql<Array<{ ciphertext: Uint8Array }>>`
|
||||
SELECT ciphertext FROM shade_sessions_enc WHERE address = ${address}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return openSession(this.km, address, rows[0]!.ciphertext);
|
||||
}
|
||||
|
||||
async saveSession(address: string, state: SessionState): Promise<void> {
|
||||
const blob = await sealSession(this.km, address, state);
|
||||
await this.sql`
|
||||
INSERT INTO shade_sessions_enc (address, ciphertext) VALUES (${address}, ${blob})
|
||||
ON CONFLICT (address) DO UPDATE SET ciphertext = EXCLUDED.ciphertext
|
||||
`;
|
||||
}
|
||||
|
||||
async removeSession(address: string): Promise<void> {
|
||||
await this.sql`DELETE FROM shade_sessions_enc WHERE address = ${address}`;
|
||||
}
|
||||
|
||||
// ─── Trust ─────────────────────────────────────────────────
|
||||
|
||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||
const rows = await this.sql<Array<{ ciphertext: Uint8Array }>>`
|
||||
SELECT ciphertext FROM shade_trusted_identities_enc WHERE address = ${address}
|
||||
`;
|
||||
if (rows.length === 0) return true; // TOFU
|
||||
const stored = await openTrust(this.km, address, rows[0]!.ciphertext);
|
||||
return constantTimeEqual(stored, identityKey);
|
||||
}
|
||||
|
||||
async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void> {
|
||||
const blob = await sealTrust(this.km, address, identityKey);
|
||||
await this.sql`
|
||||
INSERT INTO shade_trusted_identities_enc (address, ciphertext) VALUES (${address}, ${blob})
|
||||
ON CONFLICT (address) DO UPDATE SET ciphertext = EXCLUDED.ciphertext
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Identity History ──────────────────────────────────────
|
||||
|
||||
async addRetiredIdentity(identity: RetiredIdentity): Promise<void> {
|
||||
const blob = await sealRetired(this.km, identity);
|
||||
await this.sql`
|
||||
INSERT INTO shade_retired_identities_enc (retired_at, ciphertext)
|
||||
VALUES (${identity.retiredAt}, ${blob})
|
||||
ON CONFLICT (retired_at) DO UPDATE SET ciphertext = EXCLUDED.ciphertext
|
||||
`;
|
||||
}
|
||||
|
||||
async getRetiredIdentities(): Promise<RetiredIdentity[]> {
|
||||
const rows = await this.sql<Array<{ retired_at: string; ciphertext: Uint8Array }>>`
|
||||
SELECT retired_at, ciphertext FROM shade_retired_identities_enc ORDER BY retired_at DESC
|
||||
`;
|
||||
return Promise.all(rows.map((r) => openRetired(this.km, Number(r.retired_at), r.ciphertext)));
|
||||
}
|
||||
|
||||
async pruneRetiredIdentities(olderThan: number): Promise<void> {
|
||||
await this.sql`DELETE FROM shade_retired_identities_enc WHERE retired_at < ${olderThan}`;
|
||||
}
|
||||
|
||||
// ─── Stream-transfer resume state ──────────────────────────
|
||||
|
||||
async saveStreamState(state: PersistedStreamState): Promise<void> {
|
||||
const blob = await sealStreamSensitive(this.km, state);
|
||||
await this.sql`
|
||||
INSERT INTO shade_stream_state_enc (
|
||||
stream_id, direction, peer_address, status, ciphertext, created_at, updated_at
|
||||
) VALUES (
|
||||
${state.streamId}, ${state.direction}, ${state.peerAddress}, ${state.status},
|
||||
${blob}, ${state.createdAt}, ${state.updatedAt}
|
||||
)
|
||||
ON CONFLICT (stream_id) DO UPDATE SET
|
||||
direction = EXCLUDED.direction,
|
||||
peer_address = EXCLUDED.peer_address,
|
||||
status = EXCLUDED.status,
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
}
|
||||
|
||||
async getStreamState(streamId: string): Promise<PersistedStreamState | null> {
|
||||
const rows = await this.sql<Array<StreamRow>>`
|
||||
SELECT * FROM shade_stream_state_enc WHERE stream_id = ${streamId}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return this.rowToStreamState(rows[0]!);
|
||||
}
|
||||
|
||||
async removeStreamState(streamId: string): Promise<void> {
|
||||
await this.sql`DELETE FROM shade_stream_state_enc WHERE stream_id = ${streamId}`;
|
||||
}
|
||||
|
||||
async listActiveStreamStates(direction?: 'send' | 'receive'): Promise<PersistedStreamState[]> {
|
||||
const rows =
|
||||
direction === undefined
|
||||
? await this.sql<Array<StreamRow>>`
|
||||
SELECT * FROM shade_stream_state_enc
|
||||
WHERE status IN ('active','paused')
|
||||
ORDER BY updated_at DESC
|
||||
`
|
||||
: await this.sql<Array<StreamRow>>`
|
||||
SELECT * FROM shade_stream_state_enc
|
||||
WHERE status IN ('active','paused') AND direction = ${direction}
|
||||
ORDER BY updated_at DESC
|
||||
`;
|
||||
return Promise.all(rows.map((r) => this.rowToStreamState(r)));
|
||||
}
|
||||
|
||||
async pruneStreamStates(olderThan: number): Promise<void> {
|
||||
await this.sql`
|
||||
DELETE FROM shade_stream_state_enc
|
||||
WHERE status IN ('finished','aborted') AND updated_at < ${olderThan}
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Peer verifications (V3.3) ────────────────────────────
|
||||
// Fingerprints are public-by-design (intended for OOB display), so we
|
||||
// keep them plaintext alongside the encrypted tables for symmetry.
|
||||
|
||||
async savePeerVerification(v: PeerVerification): Promise<void> {
|
||||
await this.sql`
|
||||
INSERT INTO shade_peer_verifications_enc
|
||||
(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_enc 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_enc 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_enc 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_enc (peer_address, version)
|
||||
VALUES (${address}, ${next})
|
||||
ON CONFLICT (peer_address) DO UPDATE SET version = EXCLUDED.version
|
||||
`;
|
||||
return next;
|
||||
}
|
||||
|
||||
private async rowToStreamState(row: StreamRow): Promise<PersistedStreamState> {
|
||||
const sensitive = await openStreamSensitive(this.km, String(row.stream_id), row.ciphertext);
|
||||
const out: PersistedStreamState = {
|
||||
streamId: String(row.stream_id),
|
||||
direction: row.direction,
|
||||
peerAddress: String(row.peer_address),
|
||||
status: row.status,
|
||||
metadataJson: sensitive.metadataJson,
|
||||
partitionJson: sensitive.partitionJson,
|
||||
laneStateJson: sensitive.laneStateJson,
|
||||
ioDescriptorJson: sensitive.ioDescriptorJson,
|
||||
secretEnc: sensitive.secretEnc,
|
||||
secretNonce: sensitive.secretNonce,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
};
|
||||
if (sensitive.overallHashState !== undefined) out.overallHashState = sensitive.overallHashState;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamRow {
|
||||
stream_id: string;
|
||||
direction: 'send' | 'receive';
|
||||
peer_address: string;
|
||||
status: 'active' | 'paused' | 'finished' | 'aborted';
|
||||
ciphertext: Uint8Array;
|
||||
created_at: string | number;
|
||||
updated_at: string | number;
|
||||
}
|
||||
|
||||
export async function ensureEncryptedClientTables(sql: Sql): Promise<void> {
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_meta_enc (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_identity_enc (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
ciphertext BYTEA NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_config_enc (
|
||||
key TEXT PRIMARY KEY,
|
||||
ciphertext BYTEA NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_signed_prekeys_enc (
|
||||
key_id INTEGER PRIMARY KEY,
|
||||
ciphertext BYTEA NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_one_time_prekeys_enc (
|
||||
key_id INTEGER PRIMARY KEY,
|
||||
ciphertext BYTEA NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_sessions_enc (
|
||||
address TEXT PRIMARY KEY,
|
||||
ciphertext BYTEA NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_trusted_identities_enc (
|
||||
address TEXT PRIMARY KEY,
|
||||
ciphertext BYTEA NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_retired_identities_enc (
|
||||
retired_at BIGINT PRIMARY KEY,
|
||||
ciphertext BYTEA NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS shade_retired_at_enc_idx
|
||||
ON shade_retired_identities_enc(retired_at)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_stream_state_enc (
|
||||
stream_id TEXT PRIMARY KEY,
|
||||
direction TEXT NOT NULL CHECK (direction IN ('send','receive')),
|
||||
peer_address TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active','paused','finished','aborted')),
|
||||
ciphertext BYTEA NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS shade_stream_enc_peer_idx
|
||||
ON shade_stream_state_enc(peer_address)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS shade_stream_enc_updated_idx
|
||||
ON shade_stream_state_enc(updated_at)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS shade_stream_enc_status_idx
|
||||
ON shade_stream_state_enc(status, direction)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_peer_verifications_enc (
|
||||
peer_address TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
verified_at BIGINT NOT NULL,
|
||||
verified_by TEXT NOT NULL,
|
||||
identity_version BIGINT NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS shade_peer_identity_versions_enc (
|
||||
peer_address TEXT PRIMARY KEY,
|
||||
version BIGINT NOT NULL
|
||||
)
|
||||
`;
|
||||
}
|
||||
471
packages/shade-storage-encrypted/src/storage/encrypted-sqlite.ts
Normal file
471
packages/shade-storage-encrypted/src/storage/encrypted-sqlite.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import type {
|
||||
IdentityKeyPair,
|
||||
OneTimePreKey,
|
||||
PeerVerification,
|
||||
PeerVerificationSource,
|
||||
PersistedStreamState,
|
||||
RetiredIdentity,
|
||||
SessionState,
|
||||
SignedPreKey,
|
||||
StorageProvider,
|
||||
} from '@shade/core';
|
||||
import { constantTimeEqual, toBase64 } from '@shade/core';
|
||||
import { KeyManager } from '../crypto/key-manager.js';
|
||||
import {
|
||||
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
|
||||
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
|
||||
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
|
||||
sealStreamSensitive, sealTrust,
|
||||
} from '../crypto/row-codec.js';
|
||||
|
||||
/**
|
||||
* SQLite-backed StorageProvider with at-rest encryption (V3.2).
|
||||
*
|
||||
* Schema: parallel `_enc` tables alongside the unencrypted ones, so a
|
||||
* migration can run side-by-side and atomic-rename at the end. Sensitive
|
||||
* payloads are sealed with AES-256-GCM bound to (table, column, pk) AAD;
|
||||
* routing/timestamp fields stay plaintext to keep queries efficient.
|
||||
*
|
||||
* Bring your own KeyManager — see `KeyManager.open({ kind: 'passphrase' | 'keychain' | 'injected' })`.
|
||||
*/
|
||||
export class EncryptedSQLiteStorage implements StorageProvider {
|
||||
private readonly db: Database;
|
||||
private readonly km: KeyManager;
|
||||
private readonly ownsDb: boolean;
|
||||
|
||||
// Prepared statements
|
||||
private stmts!: {
|
||||
getIdentity: ReturnType<Database['prepare']>;
|
||||
saveIdentity: ReturnType<Database['prepare']>;
|
||||
getConfig: ReturnType<Database['prepare']>;
|
||||
saveConfig: ReturnType<Database['prepare']>;
|
||||
getSignedPreKey: ReturnType<Database['prepare']>;
|
||||
saveSignedPreKey: ReturnType<Database['prepare']>;
|
||||
removeSignedPreKey: ReturnType<Database['prepare']>;
|
||||
getOneTimePreKey: ReturnType<Database['prepare']>;
|
||||
saveOneTimePreKey: ReturnType<Database['prepare']>;
|
||||
removeOneTimePreKey: ReturnType<Database['prepare']>;
|
||||
countOneTimePreKeys: ReturnType<Database['prepare']>;
|
||||
getSession: ReturnType<Database['prepare']>;
|
||||
saveSession: ReturnType<Database['prepare']>;
|
||||
removeSession: ReturnType<Database['prepare']>;
|
||||
getTrust: ReturnType<Database['prepare']>;
|
||||
saveTrust: ReturnType<Database['prepare']>;
|
||||
addRetired: ReturnType<Database['prepare']>;
|
||||
listRetired: ReturnType<Database['prepare']>;
|
||||
pruneRetired: ReturnType<Database['prepare']>;
|
||||
saveStreamState: ReturnType<Database['prepare']>;
|
||||
getStreamState: ReturnType<Database['prepare']>;
|
||||
removeStreamState: ReturnType<Database['prepare']>;
|
||||
listActiveStreamStates: ReturnType<Database['prepare']>;
|
||||
listActiveByDirection: ReturnType<Database['prepare']>;
|
||||
pruneStreamStates: ReturnType<Database['prepare']>;
|
||||
getMeta: ReturnType<Database['prepare']>;
|
||||
setMeta: ReturnType<Database['prepare']>;
|
||||
savePeerVerification: ReturnType<Database['prepare']>;
|
||||
getPeerVerification: ReturnType<Database['prepare']>;
|
||||
removePeerVerification: ReturnType<Database['prepare']>;
|
||||
getPeerIdentityVersion: ReturnType<Database['prepare']>;
|
||||
upsertPeerIdentityVersion: ReturnType<Database['prepare']>;
|
||||
};
|
||||
|
||||
private constructor(db: Database, km: KeyManager, ownsDb: boolean) {
|
||||
this.db = db;
|
||||
this.km = km;
|
||||
this.ownsDb = ownsDb;
|
||||
this.ensureTables();
|
||||
this.prepareStatements();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an encrypted SQLite store. The caller supplies the KeyManager
|
||||
* (so they control the key source) and the DB path.
|
||||
*/
|
||||
static async open(opts: { dbPath?: string; keyManager: KeyManager }): Promise<EncryptedSQLiteStorage> {
|
||||
const path = opts.dbPath ?? process.env.SHADE_DB_PATH ?? '/data/shade-client.db';
|
||||
const db = new Database(path, { create: true });
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
const store = new EncryptedSQLiteStorage(db, opts.keyManager, true);
|
||||
await store.assertKeyMatchesOrPersistFingerprint();
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Wrap an existing bun:sqlite Database (caller owns it). */
|
||||
static async wrap(db: Database, km: KeyManager): Promise<EncryptedSQLiteStorage> {
|
||||
const store = new EncryptedSQLiteStorage(db, km, false);
|
||||
await store.assertKeyMatchesOrPersistFingerprint();
|
||||
return store;
|
||||
}
|
||||
|
||||
private ensureTables() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS shade_meta_enc (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS identity_enc (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
ciphertext BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_enc (
|
||||
key TEXT PRIMARY KEY,
|
||||
ciphertext BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS signed_prekeys_enc (
|
||||
key_id INTEGER PRIMARY KEY,
|
||||
ciphertext BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS one_time_prekeys_enc (
|
||||
key_id INTEGER PRIMARY KEY,
|
||||
ciphertext BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions_enc (
|
||||
address TEXT PRIMARY KEY,
|
||||
ciphertext BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS trusted_identities_enc (
|
||||
address TEXT PRIMARY KEY,
|
||||
ciphertext BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retired_identities_enc (
|
||||
retired_at INTEGER PRIMARY KEY,
|
||||
ciphertext BLOB NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_retired_at_enc ON retired_identities_enc(retired_at);
|
||||
CREATE TABLE IF NOT EXISTS stream_state_enc (
|
||||
stream_id TEXT PRIMARY KEY,
|
||||
direction TEXT NOT NULL,
|
||||
peer_address TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
ciphertext BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_enc_peer ON stream_state_enc(peer_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_enc_updated ON stream_state_enc(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_enc_status ON stream_state_enc(status, direction);
|
||||
CREATE TABLE IF NOT EXISTS peer_verifications_enc (
|
||||
peer_address TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
verified_at INTEGER NOT NULL,
|
||||
verified_by TEXT NOT NULL,
|
||||
identity_version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS peer_identity_versions_enc (
|
||||
peer_address TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
private prepareStatements() {
|
||||
this.stmts = {
|
||||
getIdentity: this.db.prepare('SELECT ciphertext FROM identity_enc WHERE id = 1'),
|
||||
saveIdentity: this.db.prepare('INSERT OR REPLACE INTO identity_enc (id, ciphertext) VALUES (1, ?)'),
|
||||
getConfig: this.db.prepare('SELECT ciphertext FROM config_enc WHERE key = ?'),
|
||||
saveConfig: this.db.prepare('INSERT OR REPLACE INTO config_enc (key, ciphertext) VALUES (?, ?)'),
|
||||
getSignedPreKey: this.db.prepare('SELECT ciphertext FROM signed_prekeys_enc WHERE key_id = ?'),
|
||||
saveSignedPreKey: this.db.prepare('INSERT OR REPLACE INTO signed_prekeys_enc (key_id, ciphertext) VALUES (?, ?)'),
|
||||
removeSignedPreKey: this.db.prepare('DELETE FROM signed_prekeys_enc WHERE key_id = ?'),
|
||||
getOneTimePreKey: this.db.prepare('SELECT ciphertext FROM one_time_prekeys_enc WHERE key_id = ?'),
|
||||
saveOneTimePreKey: this.db.prepare('INSERT OR REPLACE INTO one_time_prekeys_enc (key_id, ciphertext) VALUES (?, ?)'),
|
||||
removeOneTimePreKey: this.db.prepare('DELETE FROM one_time_prekeys_enc WHERE key_id = ?'),
|
||||
countOneTimePreKeys: this.db.prepare('SELECT COUNT(*) as count FROM one_time_prekeys_enc'),
|
||||
getSession: this.db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?'),
|
||||
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions_enc (address, ciphertext) VALUES (?, ?)'),
|
||||
removeSession: this.db.prepare('DELETE FROM sessions_enc WHERE address = ?'),
|
||||
getTrust: this.db.prepare('SELECT ciphertext FROM trusted_identities_enc WHERE address = ?'),
|
||||
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities_enc (address, ciphertext) VALUES (?, ?)'),
|
||||
addRetired: this.db.prepare('INSERT OR REPLACE INTO retired_identities_enc (retired_at, ciphertext) VALUES (?, ?)'),
|
||||
listRetired: this.db.prepare('SELECT retired_at, ciphertext FROM retired_identities_enc ORDER BY retired_at DESC'),
|
||||
pruneRetired: this.db.prepare('DELETE FROM retired_identities_enc WHERE retired_at < ?'),
|
||||
saveStreamState: this.db.prepare(
|
||||
`INSERT OR REPLACE INTO stream_state_enc (
|
||||
stream_id, direction, peer_address, status, ciphertext, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
),
|
||||
getStreamState: this.db.prepare('SELECT * FROM stream_state_enc WHERE stream_id = ?'),
|
||||
removeStreamState: this.db.prepare('DELETE FROM stream_state_enc WHERE stream_id = ?'),
|
||||
listActiveStreamStates: this.db.prepare(
|
||||
"SELECT * FROM stream_state_enc WHERE status IN ('active','paused') ORDER BY updated_at DESC"
|
||||
),
|
||||
listActiveByDirection: this.db.prepare(
|
||||
"SELECT * FROM stream_state_enc WHERE status IN ('active','paused') AND direction = ? ORDER BY updated_at DESC"
|
||||
),
|
||||
pruneStreamStates: this.db.prepare(
|
||||
"DELETE FROM stream_state_enc WHERE status IN ('finished','aborted') AND updated_at < ?"
|
||||
),
|
||||
getMeta: this.db.prepare('SELECT value FROM shade_meta_enc WHERE key = ?'),
|
||||
setMeta: this.db.prepare('INSERT OR REPLACE INTO shade_meta_enc (key, value) VALUES (?, ?)'),
|
||||
savePeerVerification: this.db.prepare(
|
||||
`INSERT OR REPLACE INTO peer_verifications_enc
|
||||
(peer_address, fingerprint, verified_at, verified_by, identity_version)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
),
|
||||
getPeerVerification: this.db.prepare(
|
||||
'SELECT peer_address, fingerprint, verified_at, verified_by, identity_version FROM peer_verifications_enc WHERE peer_address = ?',
|
||||
),
|
||||
removePeerVerification: this.db.prepare('DELETE FROM peer_verifications_enc WHERE peer_address = ?'),
|
||||
getPeerIdentityVersion: this.db.prepare('SELECT version FROM peer_identity_versions_enc WHERE peer_address = ?'),
|
||||
upsertPeerIdentityVersion: this.db.prepare(
|
||||
`INSERT INTO peer_identity_versions_enc (peer_address, version) VALUES (?, ?)
|
||||
ON CONFLICT(peer_address) DO UPDATE SET version = excluded.version`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* On first open, persist a fingerprint of the storageKey. On subsequent
|
||||
* opens, compare and reject mismatches with a clear error rather than
|
||||
* silently writing data under the wrong key.
|
||||
*/
|
||||
private async assertKeyMatchesOrPersistFingerprint(): Promise<void> {
|
||||
const expected = toBase64(this.km.storageKeyFingerprint());
|
||||
const row = this.stmts.getMeta.get('storage_key_fingerprint') as { value: string } | undefined;
|
||||
if (!row) {
|
||||
this.stmts.setMeta.run('storage_key_fingerprint', expected);
|
||||
return;
|
||||
}
|
||||
if (row.value !== expected) {
|
||||
throw new Error(
|
||||
'storage key mismatch — the supplied passphrase / keychain entry does not unlock this database',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.ownsDb) this.db.close();
|
||||
this.km.destroy();
|
||||
}
|
||||
|
||||
// ─── Identity ──────────────────────────────────────────────
|
||||
|
||||
async getIdentityKeyPair(): Promise<IdentityKeyPair | null> {
|
||||
const row = this.stmts.getIdentity.get() as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
|
||||
if (!row) return null;
|
||||
return openIdentity(this.km, toBytes(row.ciphertext));
|
||||
}
|
||||
|
||||
async saveIdentityKeyPair(kp: IdentityKeyPair): Promise<void> {
|
||||
const blob = await sealIdentity(this.km, kp);
|
||||
this.stmts.saveIdentity.run(blob);
|
||||
}
|
||||
|
||||
async getLocalRegistrationId(): Promise<number> {
|
||||
const row = this.stmts.getConfig.get('registrationId') as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
|
||||
if (!row) return 0;
|
||||
const v = await openConfig(this.km, 'registrationId', toBytes(row.ciphertext));
|
||||
return parseInt(v, 10);
|
||||
}
|
||||
|
||||
async saveLocalRegistrationId(id: number): Promise<void> {
|
||||
const blob = await sealConfig(this.km, 'registrationId', String(id));
|
||||
this.stmts.saveConfig.run('registrationId', blob);
|
||||
}
|
||||
|
||||
// ─── Signed PreKeys ────────────────────────────────────────
|
||||
|
||||
async getSignedPreKey(keyId: number): Promise<SignedPreKey | null> {
|
||||
const row = this.stmts.getSignedPreKey.get(keyId) as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
|
||||
if (!row) return null;
|
||||
return openSignedPreKey(this.km, keyId, toBytes(row.ciphertext));
|
||||
}
|
||||
|
||||
async saveSignedPreKey(key: SignedPreKey): Promise<void> {
|
||||
const blob = await sealSignedPreKey(this.km, key);
|
||||
this.stmts.saveSignedPreKey.run(key.keyId, blob);
|
||||
}
|
||||
|
||||
async removeSignedPreKey(keyId: number): Promise<void> {
|
||||
this.stmts.removeSignedPreKey.run(keyId);
|
||||
}
|
||||
|
||||
// ─── One-Time PreKeys ──────────────────────────────────────
|
||||
|
||||
async getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null> {
|
||||
const row = this.stmts.getOneTimePreKey.get(keyId) as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
|
||||
if (!row) return null;
|
||||
return openOneTimePreKey(this.km, keyId, toBytes(row.ciphertext));
|
||||
}
|
||||
|
||||
async saveOneTimePreKey(key: OneTimePreKey): Promise<void> {
|
||||
const blob = await sealOneTimePreKey(this.km, key);
|
||||
this.stmts.saveOneTimePreKey.run(key.keyId, blob);
|
||||
}
|
||||
|
||||
async removeOneTimePreKey(keyId: number): Promise<void> {
|
||||
this.stmts.removeOneTimePreKey.run(keyId);
|
||||
}
|
||||
|
||||
async getOneTimePreKeyCount(): Promise<number> {
|
||||
const row = this.stmts.countOneTimePreKeys.get() as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
// ─── Sessions ──────────────────────────────────────────────
|
||||
|
||||
async getSession(address: string): Promise<SessionState | null> {
|
||||
const row = this.stmts.getSession.get(address) as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
|
||||
if (!row) return null;
|
||||
return openSession(this.km, address, toBytes(row.ciphertext));
|
||||
}
|
||||
|
||||
async saveSession(address: string, state: SessionState): Promise<void> {
|
||||
const blob = await sealSession(this.km, address, state);
|
||||
this.stmts.saveSession.run(address, blob);
|
||||
}
|
||||
|
||||
async removeSession(address: string): Promise<void> {
|
||||
this.stmts.removeSession.run(address);
|
||||
}
|
||||
|
||||
// ─── Trust ─────────────────────────────────────────────────
|
||||
|
||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||
const row = this.stmts.getTrust.get(address) as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
|
||||
if (!row) return true; // TOFU
|
||||
const stored = await openTrust(this.km, address, toBytes(row.ciphertext));
|
||||
return constantTimeEqual(stored, identityKey);
|
||||
}
|
||||
|
||||
async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void> {
|
||||
const blob = await sealTrust(this.km, address, identityKey);
|
||||
this.stmts.saveTrust.run(address, blob);
|
||||
}
|
||||
|
||||
// ─── Identity History ──────────────────────────────────────
|
||||
|
||||
async addRetiredIdentity(identity: RetiredIdentity): Promise<void> {
|
||||
const blob = await sealRetired(this.km, identity);
|
||||
this.stmts.addRetired.run(identity.retiredAt, blob);
|
||||
}
|
||||
|
||||
async getRetiredIdentities(): Promise<RetiredIdentity[]> {
|
||||
const rows = this.stmts.listRetired.all() as { retired_at: number; ciphertext: Uint8Array | ArrayBuffer }[];
|
||||
return Promise.all(rows.map((r) => openRetired(this.km, Number(r.retired_at), toBytes(r.ciphertext))));
|
||||
}
|
||||
|
||||
async pruneRetiredIdentities(olderThan: number): Promise<void> {
|
||||
this.stmts.pruneRetired.run(olderThan);
|
||||
}
|
||||
|
||||
// ─── Stream-transfer resume state ──────────────────────────
|
||||
|
||||
async saveStreamState(state: PersistedStreamState): Promise<void> {
|
||||
const blob = await sealStreamSensitive(this.km, state);
|
||||
this.stmts.saveStreamState.run(
|
||||
state.streamId,
|
||||
state.direction,
|
||||
state.peerAddress,
|
||||
state.status,
|
||||
blob,
|
||||
state.createdAt,
|
||||
state.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async getStreamState(streamId: string): Promise<PersistedStreamState | null> {
|
||||
const row = this.stmts.getStreamState.get(streamId) as StreamRow | undefined;
|
||||
if (!row) return null;
|
||||
return this.rowToStreamState(row);
|
||||
}
|
||||
|
||||
async removeStreamState(streamId: string): Promise<void> {
|
||||
this.stmts.removeStreamState.run(streamId);
|
||||
}
|
||||
|
||||
async listActiveStreamStates(direction?: 'send' | 'receive'): Promise<PersistedStreamState[]> {
|
||||
const rows = (
|
||||
direction === undefined
|
||||
? (this.stmts.listActiveStreamStates.all() as StreamRow[])
|
||||
: (this.stmts.listActiveByDirection.all(direction) as StreamRow[])
|
||||
);
|
||||
return Promise.all(rows.map((r) => this.rowToStreamState(r)));
|
||||
}
|
||||
|
||||
async pruneStreamStates(olderThan: number): Promise<void> {
|
||||
this.stmts.pruneStreamStates.run(olderThan);
|
||||
}
|
||||
|
||||
// ─── Peer verifications (V3.3) ────────────────────────────
|
||||
// Fingerprints are public-by-design; stored in plaintext for symmetry
|
||||
// with the unencrypted backend.
|
||||
|
||||
async savePeerVerification(v: PeerVerification): Promise<void> {
|
||||
this.stmts.savePeerVerification.run(
|
||||
v.peerAddress,
|
||||
v.fingerprint,
|
||||
v.verifiedAt,
|
||||
v.verifiedBy,
|
||||
v.identityVersion,
|
||||
);
|
||||
}
|
||||
|
||||
async getPeerVerification(address: string): Promise<PeerVerification | null> {
|
||||
const row = this.stmts.getPeerVerification.get(address) as
|
||||
| { peer_address: string; fingerprint: string; verified_at: number | bigint; verified_by: string; identity_version: number | bigint }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
peerAddress: row.peer_address,
|
||||
fingerprint: row.fingerprint,
|
||||
verifiedAt: Number(row.verified_at),
|
||||
verifiedBy: row.verified_by as PeerVerificationSource,
|
||||
identityVersion: Number(row.identity_version),
|
||||
};
|
||||
}
|
||||
|
||||
async removePeerVerification(address: string): Promise<void> {
|
||||
this.stmts.removePeerVerification.run(address);
|
||||
}
|
||||
|
||||
async getPeerIdentityVersion(address: string): Promise<number> {
|
||||
const row = this.stmts.getPeerIdentityVersion.get(address) as { version: number | bigint } | undefined;
|
||||
return row ? Number(row.version) : 1;
|
||||
}
|
||||
|
||||
async bumpPeerIdentityVersion(address: string): Promise<number> {
|
||||
const current = await this.getPeerIdentityVersion(address);
|
||||
const next = current + 1;
|
||||
this.stmts.upsertPeerIdentityVersion.run(address, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private async rowToStreamState(row: StreamRow): Promise<PersistedStreamState> {
|
||||
const sensitive = await openStreamSensitive(this.km, row.stream_id, toBytes(row.ciphertext));
|
||||
const out: PersistedStreamState = {
|
||||
streamId: row.stream_id,
|
||||
direction: row.direction,
|
||||
peerAddress: row.peer_address,
|
||||
status: row.status,
|
||||
metadataJson: sensitive.metadataJson,
|
||||
partitionJson: sensitive.partitionJson,
|
||||
laneStateJson: sensitive.laneStateJson,
|
||||
ioDescriptorJson: sensitive.ioDescriptorJson,
|
||||
secretEnc: sensitive.secretEnc,
|
||||
secretNonce: sensitive.secretNonce,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
};
|
||||
if (sensitive.overallHashState !== undefined) out.overallHashState = sensitive.overallHashState;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamRow {
|
||||
stream_id: string;
|
||||
direction: 'send' | 'receive';
|
||||
peer_address: string;
|
||||
status: 'active' | 'paused' | 'finished' | 'aborted';
|
||||
ciphertext: Uint8Array | ArrayBuffer;
|
||||
created_at: number | bigint;
|
||||
updated_at: number | bigint;
|
||||
}
|
||||
|
||||
function toBytes(value: Uint8Array | ArrayBuffer | unknown): Uint8Array {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
if (Array.isArray(value)) return new Uint8Array(value as number[]);
|
||||
throw new Error(`Unsupported BLOB representation: ${typeof value}`);
|
||||
}
|
||||
Reference in New Issue
Block a user