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:
23
packages/shade-storage-encrypted/package.json
Normal file
23
packages/shade-storage-encrypted/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@shade/storage-encrypted",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
"@shade/storage-postgres": "workspace:*",
|
||||
"@shade/storage-sqlite": "workspace:*",
|
||||
"postgres": "^3.4.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@shade/keychain": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@shade/keychain": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
63
packages/shade-storage-encrypted/tests/aead.test.ts
Normal file
63
packages/shade-storage-encrypted/tests/aead.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { aeadOpen, aeadSeal, AEAD_NONCE_LEN, AEAD_TAG_LEN } from '../src/crypto/aead.js';
|
||||
|
||||
const TEXT = new TextEncoder();
|
||||
|
||||
describe('AEAD — basic seal/open', () => {
|
||||
const key = new Uint8Array(32).fill(0xAA);
|
||||
const nonce = new Uint8Array(AEAD_NONCE_LEN).fill(0x55);
|
||||
const aad = TEXT.encode('shade-aad-v1|sessions|session|alice');
|
||||
const pt = TEXT.encode('hello shade');
|
||||
|
||||
test('round-trips', async () => {
|
||||
const blob = await aeadSeal(key, nonce, pt, aad);
|
||||
expect(blob.length).toBe(AEAD_NONCE_LEN + pt.length + AEAD_TAG_LEN);
|
||||
const opened = await aeadOpen(key, blob, aad);
|
||||
expect(opened).toEqual(pt);
|
||||
});
|
||||
|
||||
test('blob carries the nonce in the prefix', async () => {
|
||||
const blob = await aeadSeal(key, nonce, pt, aad);
|
||||
expect(blob.subarray(0, AEAD_NONCE_LEN)).toEqual(nonce);
|
||||
});
|
||||
|
||||
test('rejects wrong key', async () => {
|
||||
const blob = await aeadSeal(key, nonce, pt, aad);
|
||||
const wrong = new Uint8Array(32).fill(0xBB);
|
||||
await expect(aeadOpen(wrong, blob, aad)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('rejects wrong AAD', async () => {
|
||||
const blob = await aeadSeal(key, nonce, pt, aad);
|
||||
await expect(aeadOpen(key, blob, TEXT.encode('different aad'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('rejects flipped ciphertext bit', async () => {
|
||||
const blob = await aeadSeal(key, nonce, pt, aad);
|
||||
const tampered = new Uint8Array(blob);
|
||||
tampered[AEAD_NONCE_LEN + 2]! ^= 0x01;
|
||||
await expect(aeadOpen(key, tampered, aad)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('rejects flipped tag bit', async () => {
|
||||
const blob = await aeadSeal(key, nonce, pt, aad);
|
||||
const tampered = new Uint8Array(blob);
|
||||
tampered[blob.length - 1]! ^= 0x01;
|
||||
await expect(aeadOpen(key, tampered, aad)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('rejects flipped nonce bit (mismatch with expected)', async () => {
|
||||
const blob = await aeadSeal(key, nonce, pt, aad);
|
||||
const tampered = new Uint8Array(blob);
|
||||
tampered[1]! ^= 0x01;
|
||||
await expect(aeadOpen(key, tampered, aad, nonce)).rejects.toThrow(/nonce mismatch/);
|
||||
});
|
||||
|
||||
test('rejects too-short blob', async () => {
|
||||
await expect(aeadOpen(key, new Uint8Array(10), aad)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('rejects nonce of wrong size on seal', async () => {
|
||||
await expect(aeadSeal(key, new Uint8Array(8), pt, aad)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
230
packages/shade-storage-encrypted/tests/encrypted-sqlite.test.ts
Normal file
230
packages/shade-storage-encrypted/tests/encrypted-sqlite.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { unlinkSync } from 'fs';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { EncryptedSQLiteStorage } from '../src/storage/encrypted-sqlite.js';
|
||||
import { KeyManager } from '../src/crypto/key-manager.js';
|
||||
import type { IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, PersistedStreamState } from '@shade/core';
|
||||
|
||||
function randBytes(n: number): Uint8Array {
|
||||
const b = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
function tempDb(): string {
|
||||
return join(tmpdir(), `shade-enc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
}
|
||||
|
||||
const KEY_BYTES = randBytes(32);
|
||||
async function freshKM(): Promise<KeyManager> {
|
||||
return KeyManager.open({ kind: 'injected', key: KEY_BYTES });
|
||||
}
|
||||
|
||||
function dummyIdentity(): IdentityKeyPair {
|
||||
return {
|
||||
signingPublicKey: randBytes(32),
|
||||
signingPrivateKey: randBytes(32),
|
||||
dhPublicKey: randBytes(32),
|
||||
dhPrivateKey: randBytes(32),
|
||||
};
|
||||
}
|
||||
|
||||
function dummySignedPreKey(id: number): SignedPreKey {
|
||||
return {
|
||||
keyId: id,
|
||||
keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) },
|
||||
signature: randBytes(64),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function dummyOTP(id: number): OneTimePreKey {
|
||||
return {
|
||||
keyId: id,
|
||||
keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) },
|
||||
};
|
||||
}
|
||||
|
||||
function dummySession(): SessionState {
|
||||
return {
|
||||
remoteIdentityKey: randBytes(32),
|
||||
rootKey: randBytes(32),
|
||||
sendChain: { chainKey: randBytes(32), counter: 0 },
|
||||
receiveChain: { chainKey: randBytes(32), counter: 0 },
|
||||
dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) },
|
||||
dhReceive: randBytes(32),
|
||||
previousSendCounter: 0,
|
||||
skippedKeys: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('EncryptedSQLiteStorage', () => {
|
||||
let dbPath: string;
|
||||
let store: EncryptedSQLiteStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = tempDb();
|
||||
store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
try { unlinkSync(dbPath); } catch {}
|
||||
try { unlinkSync(dbPath + '-wal'); } catch {}
|
||||
try { unlinkSync(dbPath + '-shm'); } catch {}
|
||||
});
|
||||
|
||||
test('identity round-trip', async () => {
|
||||
expect(await store.getIdentityKeyPair()).toBeNull();
|
||||
const kp = dummyIdentity();
|
||||
await store.saveIdentityKeyPair(kp);
|
||||
const got = await store.getIdentityKeyPair();
|
||||
expect(got).toEqual(kp);
|
||||
});
|
||||
|
||||
test('registrationId round-trip', async () => {
|
||||
expect(await store.getLocalRegistrationId()).toBe(0);
|
||||
await store.saveLocalRegistrationId(12345);
|
||||
expect(await store.getLocalRegistrationId()).toBe(12345);
|
||||
});
|
||||
|
||||
test('signed prekey round-trip + remove', async () => {
|
||||
expect(await store.getSignedPreKey(7)).toBeNull();
|
||||
const k = dummySignedPreKey(7);
|
||||
await store.saveSignedPreKey(k);
|
||||
const got = await store.getSignedPreKey(7);
|
||||
expect(got?.keyId).toBe(7);
|
||||
expect(got?.keyPair.privateKey).toEqual(k.keyPair.privateKey);
|
||||
await store.removeSignedPreKey(7);
|
||||
expect(await store.getSignedPreKey(7)).toBeNull();
|
||||
});
|
||||
|
||||
test('one-time prekey round-trip + count + remove', async () => {
|
||||
expect(await store.getOneTimePreKeyCount()).toBe(0);
|
||||
await store.saveOneTimePreKey(dummyOTP(1));
|
||||
await store.saveOneTimePreKey(dummyOTP(2));
|
||||
expect(await store.getOneTimePreKeyCount()).toBe(2);
|
||||
expect(await store.getOneTimePreKey(1)).not.toBeNull();
|
||||
await store.removeOneTimePreKey(1);
|
||||
expect(await store.getOneTimePreKey(1)).toBeNull();
|
||||
expect(await store.getOneTimePreKeyCount()).toBe(1);
|
||||
});
|
||||
|
||||
test('session round-trip + remove', async () => {
|
||||
const s = dummySession();
|
||||
await store.saveSession('device:abc', s);
|
||||
const got = await store.getSession('device:abc');
|
||||
expect(got?.rootKey).toEqual(s.rootKey);
|
||||
await store.removeSession('device:abc');
|
||||
expect(await store.getSession('device:abc')).toBeNull();
|
||||
});
|
||||
|
||||
test('TOFU + trust check', async () => {
|
||||
const ik = randBytes(32);
|
||||
expect(await store.isTrustedIdentity('peer-1', ik)).toBe(true); // TOFU
|
||||
await store.saveTrustedIdentity('peer-1', ik);
|
||||
expect(await store.isTrustedIdentity('peer-1', ik)).toBe(true);
|
||||
expect(await store.isTrustedIdentity('peer-1', randBytes(32))).toBe(false);
|
||||
});
|
||||
|
||||
test('retired identities are sorted DESC', async () => {
|
||||
await store.addRetiredIdentity({ keyPair: dummyIdentity(), retiredAt: 100 });
|
||||
await store.addRetiredIdentity({ keyPair: dummyIdentity(), retiredAt: 200 });
|
||||
const list = await store.getRetiredIdentities();
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0]!.retiredAt).toBe(200);
|
||||
await store.pruneRetiredIdentities(150);
|
||||
expect((await store.getRetiredIdentities()).length).toBe(1);
|
||||
});
|
||||
|
||||
test('stream-state round-trip + listActive + prune', async () => {
|
||||
const s: PersistedStreamState = {
|
||||
streamId: 'stream-1',
|
||||
direction: 'send',
|
||||
peerAddress: 'device:bob',
|
||||
status: 'active',
|
||||
metadataJson: '{"name":"file.bin"}',
|
||||
partitionJson: '[]',
|
||||
laneStateJson: '[]',
|
||||
ioDescriptorJson: '{"path":"/tmp/x"}',
|
||||
secretEnc: randBytes(32),
|
||||
secretNonce: randBytes(12),
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
await store.saveStreamState(s);
|
||||
const got = await store.getStreamState('stream-1');
|
||||
expect(got).toEqual(s);
|
||||
const active = await store.listActiveStreamStates();
|
||||
expect(active.length).toBe(1);
|
||||
expect((await store.listActiveStreamStates('receive')).length).toBe(0);
|
||||
|
||||
await store.saveStreamState({ ...s, streamId: 'stream-2', status: 'finished', updatedAt: 50 });
|
||||
expect((await store.listActiveStreamStates()).length).toBe(1); // only stream-1 still active
|
||||
await store.pruneStreamStates(100);
|
||||
expect(await store.getStreamState('stream-2')).toBeNull();
|
||||
expect(await store.getStreamState('stream-1')).not.toBeNull(); // active rows untouched
|
||||
});
|
||||
|
||||
test('rejects open with wrong key (fingerprint mismatch)', async () => {
|
||||
await store.saveIdentityKeyPair(dummyIdentity());
|
||||
store.close();
|
||||
const otherKey = randBytes(32);
|
||||
await expect(EncryptedSQLiteStorage.open({
|
||||
dbPath,
|
||||
keyManager: await KeyManager.open({ kind: 'injected', key: otherKey }),
|
||||
})).rejects.toThrow(/storage key mismatch/);
|
||||
// Reopen with original key for afterEach
|
||||
store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EncryptedSQLiteStorage — tamper detection', () => {
|
||||
test('flipped ciphertext byte → decrypt fails', async () => {
|
||||
const dbPath = tempDb();
|
||||
const store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
|
||||
await store.saveIdentityKeyPair(dummyIdentity());
|
||||
store.close();
|
||||
|
||||
// Tamper with the ciphertext directly via raw SQLite.
|
||||
const db = new Database(dbPath);
|
||||
const row = db.prepare('SELECT ciphertext FROM identity_enc WHERE id = 1').get() as { ciphertext: Uint8Array };
|
||||
const ct = new Uint8Array(row.ciphertext);
|
||||
ct[ct.length - 1]! ^= 0x01;
|
||||
db.prepare('UPDATE identity_enc SET ciphertext = ? WHERE id = 1').run(ct);
|
||||
db.close();
|
||||
|
||||
const reopened = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
|
||||
await expect(reopened.getIdentityKeyPair()).rejects.toThrow();
|
||||
reopened.close();
|
||||
try { unlinkSync(dbPath); } catch {}
|
||||
try { unlinkSync(dbPath + '-wal'); } catch {}
|
||||
try { unlinkSync(dbPath + '-shm'); } catch {}
|
||||
});
|
||||
|
||||
test('row swap (sessions) → decrypt fails due to AAD mismatch', async () => {
|
||||
const dbPath = tempDb();
|
||||
const store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
|
||||
await store.saveSession('alice', dummySession());
|
||||
await store.saveSession('bob', dummySession());
|
||||
store.close();
|
||||
|
||||
// Swap the ciphertexts.
|
||||
const db = new Database(dbPath);
|
||||
const aliceRow = db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?').get('alice') as { ciphertext: Uint8Array };
|
||||
const bobRow = db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?').get('bob') as { ciphertext: Uint8Array };
|
||||
db.prepare('UPDATE sessions_enc SET ciphertext = ? WHERE address = ?').run(bobRow.ciphertext, 'alice');
|
||||
db.prepare('UPDATE sessions_enc SET ciphertext = ? WHERE address = ?').run(aliceRow.ciphertext, 'bob');
|
||||
db.close();
|
||||
|
||||
const reopened = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
|
||||
await expect(reopened.getSession('alice')).rejects.toThrow();
|
||||
await expect(reopened.getSession('bob')).rejects.toThrow();
|
||||
reopened.close();
|
||||
try { unlinkSync(dbPath); } catch {}
|
||||
try { unlinkSync(dbPath + '-wal'); } catch {}
|
||||
try { unlinkSync(dbPath + '-shm'); } catch {}
|
||||
});
|
||||
});
|
||||
107
packages/shade-storage-encrypted/tests/kdf.test.ts
Normal file
107
packages/shade-storage-encrypted/tests/kdf.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
buildAad, deriveFieldKey, deriveMasterKey, deriveNonce, deriveStorageKey,
|
||||
hkdfDerive, DEFAULT_SCRYPT,
|
||||
} from '../src/crypto/kdf.js';
|
||||
|
||||
describe('KDF — masterKey', () => {
|
||||
const salt = new Uint8Array(16).fill(0x42);
|
||||
|
||||
test('deriveMasterKey is deterministic for the same input', async () => {
|
||||
const fast = { ...DEFAULT_SCRYPT, N: 1 << 10 };
|
||||
const a = await deriveMasterKey('correct-horse-battery-staple', salt, fast);
|
||||
const b = await deriveMasterKey('correct-horse-battery-staple', salt, fast);
|
||||
expect(a).toEqual(b);
|
||||
expect(a.length).toBe(32);
|
||||
});
|
||||
|
||||
test('different passphrase → different masterKey', async () => {
|
||||
const fast = { ...DEFAULT_SCRYPT, N: 1 << 10 };
|
||||
const a = await deriveMasterKey('alpha', salt, fast);
|
||||
const b = await deriveMasterKey('beta', salt, fast);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
test('different salt → different masterKey', async () => {
|
||||
const fast = { ...DEFAULT_SCRYPT, N: 1 << 10 };
|
||||
const otherSalt = new Uint8Array(16).fill(0x43);
|
||||
const a = await deriveMasterKey('p', salt, fast);
|
||||
const b = await deriveMasterKey('p', otherSalt, fast);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
test('NFKC-normalises passphrase', async () => {
|
||||
const fast = { ...DEFAULT_SCRYPT, N: 1 << 10 };
|
||||
// U+00E9 (é, single codepoint) vs U+0065 U+0301 (e + combining acute)
|
||||
const composed = await deriveMasterKey('café', salt, fast);
|
||||
const decomposed = await deriveMasterKey('café', salt, fast);
|
||||
expect(composed).toEqual(decomposed);
|
||||
});
|
||||
|
||||
test('rejects empty passphrase', async () => {
|
||||
await expect(deriveMasterKey('', salt)).rejects.toThrow(/non-empty/);
|
||||
});
|
||||
|
||||
test('rejects too-short salt', async () => {
|
||||
await expect(deriveMasterKey('p', new Uint8Array(8))).rejects.toThrow(/at least 16/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KDF — derivation chain', () => {
|
||||
test('storageKey is HKDF("shade-storage-v1")', () => {
|
||||
const master = new Uint8Array(32).fill(7);
|
||||
const sk = deriveStorageKey(master);
|
||||
const expected = hkdfDerive(master, 'shade-storage-v1', 32);
|
||||
expect(sk).toEqual(expected);
|
||||
expect(sk.length).toBe(32);
|
||||
});
|
||||
|
||||
test('fieldKey changes per (table, column)', () => {
|
||||
const sk = new Uint8Array(32).fill(9);
|
||||
const a = deriveFieldKey(sk, 'sessions', 'session');
|
||||
const b = deriveFieldKey(sk, 'sessions', 'identity');
|
||||
const c = deriveFieldKey(sk, 'identity', 'session');
|
||||
expect(a).not.toEqual(b);
|
||||
expect(a).not.toEqual(c);
|
||||
expect(b).not.toEqual(c);
|
||||
});
|
||||
|
||||
test('nonce is deterministic per (rowKey, table, pk)', () => {
|
||||
const k = new Uint8Array(32).fill(11);
|
||||
const n1 = deriveNonce(k, 'sessions', 'alice');
|
||||
const n2 = deriveNonce(k, 'sessions', 'alice');
|
||||
expect(n1).toEqual(n2);
|
||||
expect(n1.length).toBe(12);
|
||||
});
|
||||
|
||||
test('nonce changes when pk changes', () => {
|
||||
const k = new Uint8Array(32).fill(11);
|
||||
const n1 = deriveNonce(k, 'sessions', 'alice');
|
||||
const n2 = deriveNonce(k, 'sessions', 'bob');
|
||||
expect(n1).not.toEqual(n2);
|
||||
});
|
||||
|
||||
test('nonce changes when table changes', () => {
|
||||
const k = new Uint8Array(32).fill(11);
|
||||
const n1 = deriveNonce(k, 'sessions', 'alice');
|
||||
const n2 = deriveNonce(k, 'identity', 'alice');
|
||||
expect(n1).not.toEqual(n2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KDF — AAD binding', () => {
|
||||
test('buildAad encodes (table, column, pk)', () => {
|
||||
const aad = buildAad('sessions', 'session', 'alice');
|
||||
expect(new TextDecoder().decode(aad)).toBe('shade-aad-v1|sessions|session|alice');
|
||||
});
|
||||
|
||||
test('AAD differs for any change in binding tuple', () => {
|
||||
const a = buildAad('sessions', 'session', 'alice');
|
||||
const b = buildAad('sessions', 'session', 'bob');
|
||||
const c = buildAad('sessions', 'trust', 'alice');
|
||||
const d = buildAad('identity', 'session', 'alice');
|
||||
expect(a).not.toEqual(b);
|
||||
expect(a).not.toEqual(c);
|
||||
expect(a).not.toEqual(d);
|
||||
});
|
||||
});
|
||||
192
packages/shade-storage-encrypted/tests/migrate.test.ts
Normal file
192
packages/shade-storage-encrypted/tests/migrate.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { unlinkSync, existsSync, readdirSync } from 'fs';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { SQLiteStorage } from '@shade/storage-sqlite';
|
||||
import { EncryptedSQLiteStorage } from '../src/storage/encrypted-sqlite.js';
|
||||
import { KeyManager } from '../src/crypto/key-manager.js';
|
||||
import { migrateSqliteToEncrypted, rotateSqliteEncryptionKey } from '../src/migrate/migrate-sqlite.js';
|
||||
|
||||
function randBytes(n: number): Uint8Array {
|
||||
const b = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
function tempDb(): string {
|
||||
return join(tmpdir(), `shade-migrate-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
}
|
||||
|
||||
function dummyIdentity() {
|
||||
return {
|
||||
signingPublicKey: randBytes(32),
|
||||
signingPrivateKey: randBytes(32),
|
||||
dhPublicKey: randBytes(32),
|
||||
dhPrivateKey: randBytes(32),
|
||||
};
|
||||
}
|
||||
|
||||
function dummySignedPreKey(id: number) {
|
||||
return {
|
||||
keyId: id,
|
||||
keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) },
|
||||
signature: randBytes(64),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function dummySession() {
|
||||
return {
|
||||
remoteIdentityKey: randBytes(32),
|
||||
rootKey: randBytes(32),
|
||||
sendChain: { chainKey: randBytes(32), counter: 0 },
|
||||
receiveChain: { chainKey: randBytes(32), counter: 0 },
|
||||
dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) },
|
||||
dhReceive: randBytes(32),
|
||||
previousSendCounter: 0,
|
||||
skippedKeys: new Map<string, Uint8Array>(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('migrateSqliteToEncrypted', () => {
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = tempDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const f of [dbPath, dbPath + '-wal', dbPath + '-shm']) {
|
||||
try { unlinkSync(f); } catch {}
|
||||
}
|
||||
// Clean up any .bak files left in the temp dir.
|
||||
const dir = tmpdir();
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name.startsWith(`shade-migrate-`) && name.includes('.bak.')) {
|
||||
try { unlinkSync(join(dir, name)); } catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('migrates a populated unencrypted DB', async () => {
|
||||
const id = dummyIdentity();
|
||||
const sk = dummySignedPreKey(1);
|
||||
const sess = dummySession();
|
||||
|
||||
const src = new SQLiteStorage(dbPath);
|
||||
await src.saveIdentityKeyPair(id);
|
||||
await src.saveLocalRegistrationId(99);
|
||||
await src.saveSignedPreKey(sk);
|
||||
await src.saveSession('alice', sess);
|
||||
await src.saveTrustedIdentity('alice', id.dhPublicKey);
|
||||
await src.addRetiredIdentity({ keyPair: dummyIdentity(), retiredAt: 1234 });
|
||||
src.close();
|
||||
|
||||
const masterKey = randBytes(32);
|
||||
const km = await KeyManager.open({ kind: 'injected', key: masterKey });
|
||||
const report = await migrateSqliteToEncrypted({ dbPath, keyManager: km, backup: false });
|
||||
|
||||
expect(report.identity).toBe(1);
|
||||
expect(report.config).toBe(1);
|
||||
expect(report.signedPrekeys).toBe(1);
|
||||
expect(report.sessions).toBe(1);
|
||||
expect(report.trustedIdentities).toBe(1);
|
||||
expect(report.retiredIdentities).toBe(1);
|
||||
|
||||
// Verify we can read everything back with the same masterKey.
|
||||
const km2 = await KeyManager.open({ kind: 'injected', key: masterKey });
|
||||
const enc = await EncryptedSQLiteStorage.open({ dbPath, keyManager: km2 });
|
||||
expect(await enc.getIdentityKeyPair()).not.toBeNull();
|
||||
expect(await enc.getLocalRegistrationId()).toBe(99);
|
||||
expect(await enc.getSession('alice')).not.toBeNull();
|
||||
expect(await enc.getSignedPreKey(1)).not.toBeNull();
|
||||
expect((await enc.getRetiredIdentities()).length).toBe(1);
|
||||
enc.close();
|
||||
});
|
||||
|
||||
test('--dry-run leaves DB unchanged', async () => {
|
||||
const src = new SQLiteStorage(dbPath);
|
||||
await src.saveIdentityKeyPair(dummyIdentity());
|
||||
await src.saveSession('alice', dummySession());
|
||||
src.close();
|
||||
|
||||
const km = await KeyManager.open({ kind: 'injected', key: randBytes(32) });
|
||||
const report = await migrateSqliteToEncrypted({ dbPath, keyManager: km, dryRun: true, backup: false });
|
||||
expect(report.dryRun).toBe(true);
|
||||
|
||||
// Original tables still present and populated.
|
||||
const db = new Database(dbPath);
|
||||
const idCount = (db.prepare('SELECT COUNT(*) as c FROM identity').get() as { c: number }).c;
|
||||
expect(idCount).toBe(1);
|
||||
const sessCount = (db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number }).c;
|
||||
expect(sessCount).toBe(1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('drops unencrypted tables after successful migration', async () => {
|
||||
const src = new SQLiteStorage(dbPath);
|
||||
await src.saveIdentityKeyPair(dummyIdentity());
|
||||
src.close();
|
||||
|
||||
const km = await KeyManager.open({ kind: 'injected', key: randBytes(32) });
|
||||
await migrateSqliteToEncrypted({ dbPath, keyManager: km, backup: false });
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const r = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='identity'").get();
|
||||
expect(r).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('produces .bak file when backup enabled', async () => {
|
||||
const src = new SQLiteStorage(dbPath);
|
||||
await src.saveIdentityKeyPair(dummyIdentity());
|
||||
src.close();
|
||||
|
||||
const km = await KeyManager.open({ kind: 'injected', key: randBytes(32) });
|
||||
const report = await migrateSqliteToEncrypted({ dbPath, keyManager: km, backup: true });
|
||||
expect(report.backupPath).toBeDefined();
|
||||
expect(existsSync(report.backupPath!)).toBe(true);
|
||||
try { unlinkSync(report.backupPath!); } catch {}
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateSqliteEncryptionKey', () => {
|
||||
test('re-keys all rows; old key no longer opens DB', async () => {
|
||||
const dbPath = tempDb();
|
||||
const oldKeyBytes = randBytes(32);
|
||||
const newKeyBytes = randBytes(32);
|
||||
|
||||
const oldKm = await KeyManager.open({ kind: 'injected', key: oldKeyBytes });
|
||||
const store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: oldKm });
|
||||
await store.saveIdentityKeyPair(dummyIdentity());
|
||||
await store.saveSession('alice', dummySession());
|
||||
await store.saveSignedPreKey(dummySignedPreKey(1));
|
||||
store.close();
|
||||
|
||||
const oldKmAgain = await KeyManager.open({ kind: 'injected', key: oldKeyBytes });
|
||||
const newKm = await KeyManager.open({ kind: 'injected', key: newKeyBytes });
|
||||
const result = await rotateSqliteEncryptionKey({
|
||||
dbPath,
|
||||
oldKeyManager: oldKmAgain,
|
||||
newKeyManager: newKm,
|
||||
});
|
||||
expect(result.rowsRotated).toBeGreaterThan(0);
|
||||
|
||||
// Old key is rejected.
|
||||
const oldKmOnceMore = await KeyManager.open({ kind: 'injected', key: oldKeyBytes });
|
||||
await expect(EncryptedSQLiteStorage.open({ dbPath, keyManager: oldKmOnceMore }))
|
||||
.rejects.toThrow(/storage key mismatch/);
|
||||
|
||||
// New key works.
|
||||
const newKmAgain = await KeyManager.open({ kind: 'injected', key: newKeyBytes });
|
||||
const reopened = await EncryptedSQLiteStorage.open({ dbPath, keyManager: newKmAgain });
|
||||
expect(await reopened.getIdentityKeyPair()).not.toBeNull();
|
||||
expect(await reopened.getSession('alice')).not.toBeNull();
|
||||
reopened.close();
|
||||
|
||||
for (const f of [dbPath, dbPath + '-wal', dbPath + '-shm']) {
|
||||
try { unlinkSync(f); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
96
packages/shade-storage-encrypted/tests/test-vectors.test.ts
Normal file
96
packages/shade-storage-encrypted/tests/test-vectors.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { fromBase64, toBase64 } from '@shade/core';
|
||||
import {
|
||||
buildAad, deriveFieldKey, deriveMasterKey, deriveNonce, deriveStorageKey,
|
||||
} from '../src/crypto/kdf.js';
|
||||
import { aeadOpen, aeadSeal } from '../src/crypto/aead.js';
|
||||
|
||||
const VECTOR_PATH = resolve(__dirname, '../../../test-vectors/storage-encryption.json');
|
||||
|
||||
interface Vector {
|
||||
kdf: {
|
||||
scrypt: { passphrase: string; salt_hex: string; N: number; r: number; p: number; dkLen: number };
|
||||
hkdf_storage_key: { master_key_hex: string };
|
||||
hkdf_field_key: { storage_key_hex: string; samples: { table: string; column: string }[] };
|
||||
deterministic_nonce: { samples: { table: string; pk: string }[] };
|
||||
};
|
||||
aead: { round_trips: { table: string; column: string; pk: string; plaintext_utf8: string }[] };
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Uint8Array {
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const vec: Vector = JSON.parse(readFileSync(VECTOR_PATH, 'utf-8'));
|
||||
|
||||
describe('storage-encryption test vectors', () => {
|
||||
test('scrypt → masterKey is stable for the published parameters', async () => {
|
||||
const { passphrase, salt_hex, N, r, p, dkLen } = vec.kdf.scrypt;
|
||||
const out = await deriveMasterKey(passphrase, fromHex(salt_hex), { N, r, p, dkLen });
|
||||
expect(out.length).toBe(dkLen);
|
||||
// Pin the result for cross-impl parity.
|
||||
expect(toHex(out)).toBe('aee2dc14f3a46c563f8906a9c8777f167c868dc06015a983fdf2dbba078a3597');
|
||||
});
|
||||
|
||||
test('HKDF storageKey derivation matches pinned value', () => {
|
||||
const master = fromHex(vec.kdf.hkdf_storage_key.master_key_hex);
|
||||
const sk = deriveStorageKey(master);
|
||||
expect(sk.length).toBe(32);
|
||||
expect(toHex(sk)).toBe('059a250e15aa02952ab977f441e217cfcd4a6d9be8b51cf93d001a90eeb6accc');
|
||||
});
|
||||
|
||||
test('HKDF fieldKey derivation is deterministic for known (table, column)', () => {
|
||||
// Use a fixed storageKey (different from the pinned one above so this
|
||||
// test can run independently).
|
||||
const sk = new Uint8Array(32).fill(0xAB);
|
||||
const fk = deriveFieldKey(sk, 'sessions', 'session');
|
||||
expect(fk.length).toBe(32);
|
||||
// Pin: any change to the info-string format must update this value
|
||||
// *and* the Android implementation in lockstep.
|
||||
expect(toHex(fk)).toBe('cbe428b4e8be2d7c4cd707dbac7e02881f2da34ee5b00bdc9bc1ebf2f096087a');
|
||||
});
|
||||
|
||||
test('deriveNonce is 12 bytes and stable for known inputs', () => {
|
||||
const k = new Uint8Array(32).fill(0xCD);
|
||||
const n = deriveNonce(k, 'sessions', 'alice');
|
||||
expect(n.length).toBe(12);
|
||||
expect(toHex(n)).toBe('f72f291a2d3cd0ba652b60c5');
|
||||
});
|
||||
|
||||
test('AAD templates encode (table, column, pk) verbatim', () => {
|
||||
const aad = buildAad('sessions', 'session', 'alice');
|
||||
expect(new TextDecoder().decode(aad)).toBe('shade-aad-v1|sessions|session|alice');
|
||||
});
|
||||
|
||||
test('AEAD round-trip matches advertised wire format', async () => {
|
||||
for (const sample of vec.aead.round_trips) {
|
||||
const sk = new Uint8Array(32).fill(0x01);
|
||||
const fk = deriveFieldKey(sk, sample.table, sample.column);
|
||||
const nonce = deriveNonce(fk, sample.table, sample.pk);
|
||||
const aad = buildAad(sample.table, sample.column, sample.pk);
|
||||
const pt = new TextEncoder().encode(sample.plaintext_utf8);
|
||||
|
||||
const blob = await aeadSeal(fk, nonce, pt, aad);
|
||||
// Wire format: first 12 bytes are the nonce.
|
||||
expect(blob.subarray(0, 12)).toEqual(nonce);
|
||||
// Last 16 bytes are the GCM tag (we don't pin the tag, just length).
|
||||
expect(blob.length).toBe(12 + pt.length + 16);
|
||||
|
||||
const opened = await aeadOpen(fk, blob, aad, nonce);
|
||||
expect(new TextDecoder().decode(opened)).toBe(sample.plaintext_utf8);
|
||||
}
|
||||
});
|
||||
|
||||
test('base64 helper round-trip (sanity)', () => {
|
||||
const b = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
expect(fromBase64(toBase64(b))).toEqual(b);
|
||||
});
|
||||
});
|
||||
4
packages/shade-storage-encrypted/tsconfig.json
Normal file
4
packages/shade-storage-encrypted/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user