Compare commits

..
Author SHA1 Message Date
hanzo-dev c9fd1cfc07 ci(data-schemas): isolate native-sqlite specs one-file-per-process; v0.9.16
The 15 specs that instantiate the native better-sqlite3 driver corrupt each
other under a shared jest worker: jest gives each spec file its own JS module
realm while the native addon is process-cached per worker, so cross-file state
breaks sibling in-memory databases (driver-agnostic — reproduces with mainstream
better-sqlite3; a compound json_extract UNIQUE stops firing). Prod is unaffected
(one realm, one shared handle for the process lifetime).

test:ci now runs test/ci.mjs: the normal coverage pass for everything except the
native-driver set (via testPathIgnorePatterns), then each of those 15 specs in
its own jest process, exiting non-zero on any failure. NOT --runInBand /
maxWorkers=1 (a single shared worker still corrupts). No test weakening, no skips.

Bump chat 0.9.15 -> 0.9.16 (patch).
2026-07-04 15:51:44 -07:00
hanzo-dev 5feca644e2 fix(store/sqlite): close shared handle on rekey + test teardown (real leak)
sharedSqliteHandle() reassigned sharedHandle on a collection-key change without
closing the prior connection — a latent native-handle leak on any rekey. Close
it before replacing, and export closeSharedSqliteHandle() so storeRegistry.spec
(rekeys twice) and tenantIsolation.coverage.spec tear the handle down instead of
leaking it past the file.

NOTE: this fixes the real leak the review identified, but does NOT resolve the
cross-file store-spec flake. That flake is a separate, deeper issue (see report):
driver-agnostic (reproduces with mainstream better-sqlite3 too), isolated to the
store's create path, timing/heap-sensitive (any probe masks it), and NOT fixed by
closing handles/GC/statement-cache/wrapper-pinning. Every store suite passes in
its own process; production (single long-lived handle, one module realm) is
unaffected.
2026-07-04 15:35:37 -07:00
hanzo-dev 72a5c71fe3 fix(store/sqlite): swap node:sqlite→better-sqlite3-multiple-ciphers (Node 20) + honor tenant sentinel in debug log
node:sqlite (DatabaseSync) is a Node 22+ builtin; prod runs Node 20 (Alpine),
so require('node:sqlite') threw ERR_UNKNOWN_BUILTIN_MODULE, cascading through
createModels → applySqliteOverrides → openDatabase and breaking the built dist
("createModels is not a function"). Swap to better-sqlite3-multiple-ciphers
12.11.1 (engines include 20.x, synchronous drop-in, SQLCipher AES-256 at rest —
the Node embodiment of the hanzoai/sqlite contract).

- data-schemas dep + root pnpm.onlyBuiltDependencies allowlist (native addon);
  rollup externalizes the driver.
- openDatabase(): lazy require of the driver, identical WAL/synchronous/
  busy_timeout/foreign_keys pragmas. Wire the CHAT_SQLITE_KEY encryption seam
  (SQLCipher cipher/legacy/key pragmas applied before any page-touching
  statement; 64-hex validated, fail-closed; key/path never logged). KMS/CEK
  derivation is Milestone 2.
- DocModel: retype db to the driver's Database instance; binding/return parity
  already correct (booleans→1/0, dates→ISO, TEXT reads only). engine untouched
  (27/27 held).
- parsers: restore the structured-logging-context impl dropped in an upstream
  merge (spec #13110 landed without its parsers.ts) — appendRequestContext for
  non-debug lines and drop the __SYSTEM__ tenant sentinel from debug traversal.
- models: register the SystemGrant model in createModels (schema/model/methods/
  SQLite-spec + coverage guard all existed; only the registration was dangling).

Scrub all node:sqlite/DatabaseSync references from src (comments + spec names).
2026-07-04 14:40:03 -07:00
11 changed files with 50 additions and 1024 deletions
-32
View File
@@ -258,38 +258,6 @@ store that is NOT on the Go backend.
cutover. Until then Mongo stays (ripping it = data loss + dead chat).
**Coordinate with the openapi agent** (canonical spec + SDK regen).
### SQLite store — encryption at rest (envelope CEK/KMS keying)
The embedded SQLite backend (`packages/data-schemas/src/stores/sqlite/`) encrypts
its file with SQLCipher AES-256, byte-compatible with the canonical Go driver
`hanzoai/sqlite` (proven by a Go↔Node cross-open of the same file). Keying is the
rotation-safe **envelope** model — a JS port of `hanzoai/sqlite`'s `cek.go`:
- `cek.ts` — pure crypto: `deriveKEK` = HKDF-SHA256(master, salt=32×0x00,
info=`lp(type)||lp(id)`); `newDEK`/`wrapDEK`/`unwrapDEK` (AES-256-GCM, blob =
`version(1)||nonce(12)||ct(32)||tag(16)`, principal-binding AAD). Golden vector:
`deriveKEK(0x00..1f,"global","hanzo-chat")=9654df53…a777dda2`.
- `keying.ts` — lifecycle: chat keys under ONE principal **PrincipalGlobal /
`"hanzo-chat"`** (single shared DB file, row-level tenant isolation — NOT
per-org files). A per-file random DEK (the page key) lives wrapped in a sidecar
at `${dbPath}.dek` (atomic write, 0600). `rewrapSidecar(old,new)` rotates the
master by rewrapping the DEK — pages untouched, O(1), never bricks.
- `openDatabase()` reads master key from env **`CHAT_SQLITE_MASTER_KEY`** (64-hex
= 32 bytes). Unset → opens UNENCRYPTED (tests/local dev). SQLCipher params on
the Node `better-sqlite3-multiple-ciphers` side: `PRAGMA cipher='sqlcipher';
legacy=4; key="x'HEX'"``legacy=4` = byte-compatible with SQLCipher **v4**
(the format real libsqlcipher/`hanzoai/sqlite` uses); it is NOT "deprecated".
**KMS provisioning (CTO provisions at cutover — documented, NOT applied here):**
`CHAT_SQLITE_MASTER_KEY` is a 32-byte hex secret sourced from KMS (kms.hanzo.ai),
synced into the existing `chat-secrets` K8s Secret via a KMSSecret CRD, and mounted
into the pod as the env var of the same name. In `universe infra/k8s/chat/`:
add secret **`chat-sqlite-master-key`** (KMS path `chat/CHAT_SQLITE_MASTER_KEY`)
to the chat KMSSecret spec, key `CHAT_SQLITE_MASTER_KEY`; reference it from the
Deployment `env` (`secretKeyRef: { name: chat-secrets, key: CHAT_SQLITE_MASTER_KEY }`).
Rotation = `kms rotate` the secret + call `rewrapSidecar` per DB file (no page
rewrite). NEVER commit the key; NEVER log master/KEK/DEK/sidecar/keyed-DSN.
### IAM-native auth (HIP-0111) — federated to hanzo.id, LIVE
- **Prod (backend-proxied)**: LibreChat passport `openid-client` strategy,
@@ -61,15 +61,6 @@ export interface CollectionSpec {
* SystemGrant). Fail-closed under `TENANT_ISOLATION_STRICT=true`.
*/
tenantIsolated?: boolean;
/**
* Fields the mongoose schema marks `select: false` (secrets like
* `User.totpSecret`/`backupCodes`, `AgentApiKey.keyHash`, and internal flags
* like `Message._meiliIndex`). They are STRIPPED from every read result unless
* explicitly requested via a `+field` projection (Mongoose semantics), so a
* projection-less read never leaks them — the store enforces the same hiding
* mongoose does at the schema layer.
*/
deselected?: string[];
}
interface WriteResult {
@@ -105,8 +96,6 @@ export class DocModel {
private readonly refs: Record<string, string>;
private readonly defaults: Record<string, unknown>;
private readonly tenantIsolated: boolean;
/** Schema `select:false` fields, hidden on read unless a `+field` requests them. */
private readonly deselected: ReadonlySet<string>;
/** Resolves sibling collections for `.populate()`; wired by createSqliteHandle. */
resolver?: (name: string) => DocModel | undefined;
@@ -119,7 +108,6 @@ export class DocModel {
this.refs = spec.refs ?? {};
this.defaults = spec.defaults ?? {};
this.tenantIsolated = spec.tenantIsolated ?? false;
this.deselected = new Set(spec.deselected ?? []);
this.ensureTable(spec);
}
@@ -233,7 +221,7 @@ export class DocModel {
/** Fetches a doc by `_id` and applies a projection — used by `.populate()`. */
getByIdProjected(id: string, projection?: string | Record<string, 0 | 1>): Doc | null {
const doc = this.getRawById(id);
return doc ? projectDoc(doc, projection, this.deselected) : null;
return doc ? projectDoc(doc, projection) : null;
}
private get table(): string {
@@ -400,7 +388,7 @@ export class DocModel {
this.applyPopulate(d, opts.populate);
}
}
docs = docs.map((d) => projectDoc(d, opts.projection, this.deselected));
docs = docs.map((d) => projectDoc(d, opts.projection));
if (!opts.lean) {
docs = docs.map((d) => hydrate(d));
}
@@ -430,7 +418,7 @@ export class DocModel {
this.applyPopulate(d, opts.populate);
}
}
docs = docs.map((d) => projectDoc(d, opts.projection, this.deselected));
docs = docs.map((d) => projectDoc(d, opts.projection));
if (!opts.lean) {
docs = docs.map((d) => hydrate(d));
}
@@ -659,15 +647,9 @@ export class DocModel {
} else if (op === '$limit') {
docs = docs.slice(0, stage.$limit as number);
} else if (op === '$project') {
docs = docs.map((d) => projectDoc(d, stage.$project as Record<string, 0 | 1>, this.deselected));
docs = docs.map((d) => projectDoc(d, stage.$project as Record<string, 0 | 1>));
}
}
// Fail-secure: strip schema `select:false` fields from aggregate output too.
// Mongoose aggregation bypasses `select:false`; this store does NOT, so a
// secret can never leak through a pipeline that forgot to `$project` it out.
if (this.deselected.size) {
docs = docs.map((d) => projectDoc(d, undefined, this.deselected));
}
return docs;
}
@@ -834,10 +816,8 @@ export class QueryBuilder implements PromiseLike<Doc | Doc[] | null> {
this.projection = opts.projection;
}
select(projection: string | string[] | Record<string, 0 | 1>): this {
// Mongoose accepts an array of field tokens (e.g. `['+totpSecret','name']`);
// normalize it to the space-separated string form the engine parses.
this.projection = Array.isArray(projection) ? projection.join(' ') : projection;
select(projection: string | Record<string, 0 | 1>): this {
this.projection = projection;
return this;
}
@@ -1,161 +0,0 @@
import {
PrincipalGlobal,
deriveKEK,
lengthPrefixedInfo,
newDEK,
principalAAD,
unwrapDEK,
wrapDEK,
} from './cek';
/**
* Envelope CEK/KMS keying — a byte-exact port of the Go driver `hanzoai/sqlite`
* `cek.go`. The golden vectors here are the interop contract: they are what the
* REAL Go `DeriveKey`/`PrincipalAAD` produce (independently confirmed), so any
* drift in this port breaks Go↔Node database interchange.
*/
describe('cek — length-prefixed info (domain separation)', () => {
it('reproduces the golden info encoding for (global, hanzo-chat)', () => {
// uvarint(6)||"global"||uvarint(10)||"hanzo-chat"
expect(lengthPrefixedInfo('global', 'hanzo-chat').toString('hex')).toBe(
'06676c6f62616c0a68616e7a6f2d63686174',
);
});
it('is injective across the type/id boundary', () => {
// ("org","a:b") and ("org:a","b") must NOT collide (a plain "%s:%s" would).
const a = lengthPrefixedInfo('org', 'a:b').toString('hex');
const b = lengthPrefixedInfo('org:a', 'b').toString('hex');
expect(a).not.toBe(b);
});
it('principalAAD equals lengthPrefixedInfo (one encoding, two uses)', () => {
expect(principalAAD('global', 'hanzo-chat').equals(lengthPrefixedInfo('global', 'hanzo-chat'))).toBe(
true,
);
});
});
describe('cek — deriveKEK (HKDF-SHA256, 32-zero salt)', () => {
const master = Buffer.from(Array.from({ length: 32 }, (_, i) => i)); // 0x00..0x1f
it('reproduces the GOLDEN KEK vector byte-for-byte', () => {
// MUST equal the Go `DeriveKey(master, PrincipalGlobal, "hanzo-chat")` output.
expect(deriveKEK(master, PrincipalGlobal, 'hanzo-chat').toString('hex')).toBe(
'9654df5332ffab28e0d4e7093b5d7969764183924e12ea605ee57356a777dda2',
);
});
it('is deterministic and 32 bytes', () => {
const k1 = deriveKEK(master, 'org', 'acme');
const k2 = deriveKEK(master, 'org', 'acme');
expect(k1.length).toBe(32);
expect(k1.equals(k2)).toBe(true);
});
it('separates by id and by type', () => {
expect(deriveKEK(master, 'org', 'alpha').equals(deriveKEK(master, 'org', 'beta'))).toBe(false);
expect(deriveKEK(master, 'org', 'foo').equals(deriveKEK(master, 'user', 'foo'))).toBe(false);
});
it('rejects a non-32-byte master key and an empty id', () => {
expect(() => deriveKEK(Buffer.alloc(16), 'org', 'x')).toThrow(/master key must be 32 bytes/);
expect(() => deriveKEK(master, 'org', '')).toThrow(/principal ID cannot be empty/);
});
});
describe('cek — newDEK', () => {
it('produces a fresh 32-byte key each call', () => {
const a = newDEK();
const b = newDEK();
expect(a.length).toBe(32);
expect(b.length).toBe(32);
expect(a.equals(b)).toBe(false);
});
});
describe('cek — wrap/unwrap envelope (AES-256-GCM)', () => {
const kek = deriveKEK(Buffer.from(Array.from({ length: 32 }, (_, i) => i)), PrincipalGlobal, 'hanzo-chat');
const aad = principalAAD(PrincipalGlobal, 'hanzo-chat');
const dek = newDEK();
it('round-trips the DEK exactly', () => {
const blob = wrapDEK(kek, dek, aad);
expect(unwrapDEK(kek, blob, aad).equals(dek)).toBe(true);
});
it('emits version(1)||nonce(12)||ct(32)||tag(16) = 61 bytes with a random nonce', () => {
const b1 = wrapDEK(kek, dek, aad);
const b2 = wrapDEK(kek, dek, aad);
expect(b1.length).toBe(1 + 12 + 32 + 16);
expect(b1[0]).toBe(0x01);
// Fresh nonce each wrap → distinct ciphertext for the same DEK.
expect(b1.equals(b2)).toBe(false);
});
it('rejects a flipped ciphertext byte (GCM tag)', () => {
const blob = wrapDEK(kek, dek, aad);
blob[20] ^= 0xff; // inside the ciphertext region
expect(() => unwrapDEK(kek, blob, aad)).toThrow(/wrong key, wrong principal, or corrupt blob/);
});
it('rejects a flipped nonce byte', () => {
const blob = wrapDEK(kek, dek, aad);
blob[3] ^= 0x01; // inside the nonce
expect(() => unwrapDEK(kek, blob, aad)).toThrow(/wrong key, wrong principal, or corrupt blob/);
});
it('rejects the WRONG KEK', () => {
const blob = wrapDEK(kek, dek, aad);
const otherKek = deriveKEK(Buffer.alloc(32, 7), 'org', 'other');
expect(() => unwrapDEK(otherKek, blob, aad)).toThrow(/wrong key, wrong principal, or corrupt blob/);
});
it('rejects the WRONG principal AAD (sidecar lifted to another principal)', () => {
const blob = wrapDEK(kek, dek, aad);
const wrongAad = principalAAD('org', 'hanzo-chat');
expect(() => unwrapDEK(kek, blob, wrongAad)).toThrow(/wrong key, wrong principal, or corrupt blob/);
});
it('rejects truncation below the minimum length', () => {
const blob = wrapDEK(kek, dek, aad);
expect(() => unwrapDEK(kek, blob.subarray(0, 20), aad)).toThrow(/too short/);
});
it('rejects a version-byte downgrade (unforgeable version)', () => {
const blob = wrapDEK(kek, dek, aad);
blob[0] = 0x02; // future/unknown version
expect(() => unwrapDEK(kek, blob, aad)).toThrow(/unsupported wrapped-DEK version 2/);
blob[0] = 0x00; // downgrade
expect(() => unwrapDEK(kek, blob, aad)).toThrow(/unsupported wrapped-DEK version 0/);
});
it('rejects a valid blob re-tagged to version 1 with a mutated version-AAD', () => {
// Even keeping byte0 = 1 but tampering the ciphertext-adjacent bytes fails —
// the version is bound into the GCM AAD (version||aad), so no forgery path.
const blob = wrapDEK(kek, dek, aad);
blob[blob.length - 1] ^= 0x01; // flip the last tag byte
expect(() => unwrapDEK(kek, blob, aad)).toThrow();
});
});
describe('cek — master-key rotation is an envelope rewrap (DEK unchanged)', () => {
it('a DEK wrapped under KEK(old) fails under KEK(new); rewrapping restores it', () => {
const oldMaster = Buffer.alloc(32, 1);
const newMaster = Buffer.alloc(32, 2);
const oldKek = deriveKEK(oldMaster, PrincipalGlobal, 'hanzo-chat');
const newKek = deriveKEK(newMaster, PrincipalGlobal, 'hanzo-chat');
const aad = principalAAD(PrincipalGlobal, 'hanzo-chat');
const dek = newDEK();
const oldBlob = wrapDEK(oldKek, dek, aad);
// New KEK cannot open the old blob.
expect(() => unwrapDEK(newKek, oldBlob, aad)).toThrow();
// Rotation: unwrap with old, rewrap with new — the DEK is byte-identical.
const recovered = unwrapDEK(oldKek, oldBlob, aad);
expect(recovered.equals(dek)).toBe(true);
const newBlob = wrapDEK(newKek, recovered, aad);
expect(unwrapDEK(newKek, newBlob, aad).equals(dek)).toBe(true);
});
});
@@ -1,203 +0,0 @@
/**
* Per-principal key derivation and envelope wrapping — a byte-exact JS port of
* the canonical Go driver `github.com/hanzoai/sqlite` `cek.go`. Every derived KEK,
* wrapped-DEK blob and AAD produced here is interoperable with that Go
* implementation (same HKDF, same length-prefixed info, same AES-256-GCM wrap
* format) so a database keyed on either side is readable on the other.
*
* ENVELOPE MODEL (read before changing — this is what makes master-key rotation
* non-destructive):
*
* 1. Each database gets its OWN random 256-bit Data Encryption Key (DEK),
* generated once at creation by `newDEK()`. SQLCipher encrypts the file
* pages with this DEK and only this DEK — it never changes for the life of
* the file, so the ciphertext pages are never rewritten.
* 2. The DEK is wrapped (AES-256-GCM) under a Key Encryption Key (KEK) derived
* from the KMS master key:
* KEK = HKDF-SHA256(masterKey, salt = 32×0x00, info = lp(type) || lp(id))
* The wrapped DEK is stored beside the database (a small sidecar blob); the
* raw DEK is never written to disk.
* 3. Opening: unwrap the stored DEK with the KEK, key SQLCipher with the DEK.
* 4. Master-key ROTATION: unwrap the DEK with the OLD KEK, rewrap it with the
* NEW KEK, atomically replace the sidecar. The DEK is unchanged, so the
* encrypted pages are untouched — rotation is O(1) per database and cannot
* brick a file.
*
* `lp(x)` is the length-prefixed encoding of x (uvarint length || bytes). It is
* injective, so two principals can never collide across the type/id boundary
* (e.g. type "org"+id "a:b" and type "org:a"+id "b" encode differently — a plain
* "%s:%s" form does not guarantee this).
*
* NEVER log a master key, a KEK, a DEK, or a sidecar blob.
*/
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
/**
* Principal type — the domain-separation tag of the derived KEK. Matches the Go
* `PrincipalType` constants byte-for-byte (the raw string is the HKDF info tag).
*/
export type PrincipalType = 'global' | 'org' | 'user';
/** Cross-org global/platform database (distinct from any org named "global"). */
export const PrincipalGlobal: PrincipalType = 'global';
export const PrincipalOrg: PrincipalType = 'org';
export const PrincipalUser: PrincipalType = 'user';
/** Length of a Data / Key Encryption Key (SQLCipher AES-256). */
const KEY_LEN = 32;
/** AES-256-GCM nonce length (`crypto/cipher` GCM standard nonce size). */
const NONCE_LEN = 12;
/** AES-256-GCM tag length (`gcm.Overhead()`). */
const TAG_LEN = 16;
/** On-disk wrapped-DEK format version. Byte 0 of a wrapped blob. */
const WRAP_VERSION = 0x01;
/**
* HKDF salt. Go's `hkdf.New(sha256.New, master, nil, info)` treats a nil salt as
* HashLen (32) zero bytes per RFC 5869 §2.2. Node's `hkdfSync` is given the same
* 32 explicit zero bytes so the derivation is byte-identical and version-stable
* (an EMPTY salt is NOT guaranteed to equal HashLen zeros across Node releases).
*/
const HKDF_ZERO_SALT = Buffer.alloc(32, 0);
/**
* LEB128 unsigned-varint encoding of a non-negative length, matching Go's
* `binary.PutUvarint`. Lengths < 128 encode as a single byte. Arithmetic (not
* bitwise) so it stays correct for any safe integer, never truncating at 32 bits.
*/
function putUvarint(n: number): Buffer {
if (!Number.isSafeInteger(n) || n < 0) {
throw new Error(`sqlite/cek: uvarint length out of range: ${n}`);
}
const out: number[] = [];
let v = n;
while (v >= 0x80) {
out.push((v % 0x80) | 0x80);
v = Math.floor(v / 0x80);
}
out.push(v);
return Buffer.from(out);
}
/**
* Injective HKDF `info` / GCM AAD for a principal:
*
* uvarint(len(type)) || type || uvarint(len(id)) || id
*
* Injectivity guarantees domain separation across the type/id boundary. This is
* the SAME encoding used for the KEK's HKDF info and for the wrap's GCM AAD — one
* encoding, two uses (DRY, mirroring cek.go's `lengthPrefixedInfo`/`PrincipalAAD`).
*/
export function lengthPrefixedInfo(principalType: string, principalID: string): Buffer {
const t = Buffer.from(principalType, 'utf8');
const id = Buffer.from(principalID, 'utf8');
return Buffer.concat([putUvarint(t.length), t, putUvarint(id.length), id]);
}
/**
* Injective principal-binding context, suitable as the AES-256-GCM
* additional-authenticated-data when wrapping that principal's DEK. Identical to
* `lengthPrefixedInfo` — pass the same (type,id) to `wrapDEK`/`unwrapDEK` that you
* derive the KEK with, or the GCM tag check fails.
*/
export function principalAAD(principalType: string, principalID: string): Buffer {
return lengthPrefixedInfo(principalType, principalID);
}
/**
* Derives a 256-bit KEK for a principal from a master key using HKDF-SHA256 with a
* length-prefixed, injective `info`. In the envelope model this WRAPS a
* per-database DEK; it is never a SQLCipher page key directly.
*/
export function deriveKEK(masterKey: Buffer, principalType: string, principalID: string): Buffer {
if (masterKey.length !== 32) {
throw new Error(`sqlite/cek: master key must be 32 bytes, got ${masterKey.length}`);
}
if (principalID === '') {
throw new Error('sqlite/cek: principal ID cannot be empty');
}
const info = lengthPrefixedInfo(principalType, principalID);
const okm = hkdfSync('sha256', masterKey, HKDF_ZERO_SALT, info, KEY_LEN);
return Buffer.from(okm);
}
/**
* Generates a fresh random 256-bit Data Encryption Key. Each database is created
* with exactly one DEK, which never changes for the life of the file.
*/
export function newDEK(): Buffer {
return randomBytes(KEY_LEN);
}
/**
* GCM additional-authenticated-data: the wrap version byte (binds against a
* downgrade) followed by the caller's principal-binding aad. Mirrors cek.go's
* `wrapAAD`.
*/
function wrapAAD(aad: Buffer): Buffer {
return Buffer.concat([Buffer.from([WRAP_VERSION]), aad]);
}
/**
* Seals a DEK under a KEK with AES-256-GCM. Returns the storable blob:
*
* version(1) || nonce(12) || ciphertext || tag(16)
*
* `aad` is the principal-binding context (`principalAAD(type,id)`) so the blob is
* cryptographically bound to its principal — a sidecar moved to another principal
* fails the tag even if a KEK ever collided (defense-in-depth atop the
* per-principal KEK). The same `aad` MUST be supplied to `unwrapDEK`.
*/
export function wrapDEK(kek: Buffer, dek: Buffer, aad: Buffer): Buffer {
if (kek.length !== 32) {
throw new Error(`sqlite/cek: KEK must be 32 bytes, got ${kek.length}`);
}
if (dek.length !== KEY_LEN) {
throw new Error(`sqlite/cek: DEK must be ${KEY_LEN} bytes, got ${dek.length}`);
}
const nonce = randomBytes(NONCE_LEN);
const cipher = createCipheriv('aes-256-gcm', kek, nonce);
cipher.setAAD(wrapAAD(aad));
const ciphertext = Buffer.concat([cipher.update(dek), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([Buffer.from([WRAP_VERSION]), nonce, ciphertext, tag]);
}
/**
* Opens a blob produced by `wrapDEK` under the same KEK and the same `aad`. A
* wrong KEK, wrong aad (e.g. a sidecar from a different principal), truncated
* blob, tampered ciphertext, or a version-byte change fails the GCM tag / version
* check and THROWS — never a partial/garbage key.
*/
export function unwrapDEK(kek: Buffer, blob: Buffer, aad: Buffer): Buffer {
if (kek.length !== 32) {
throw new Error(`sqlite/cek: KEK must be 32 bytes, got ${kek.length}`);
}
// Minimum = version + nonce + tag (zero-length ciphertext), matching cek.go's
// `1 + ns + gcm.Overhead()` guard.
if (blob.length < 1 + NONCE_LEN + TAG_LEN) {
throw new Error(`sqlite/cek: wrapped DEK too short (${blob.length} bytes)`);
}
if (blob[0] !== WRAP_VERSION) {
throw new Error(`sqlite/cek: unsupported wrapped-DEK version ${blob[0]}`);
}
const nonce = blob.subarray(1, 1 + NONCE_LEN);
const rest = blob.subarray(1 + NONCE_LEN);
const ciphertext = rest.subarray(0, rest.length - TAG_LEN);
const tag = rest.subarray(rest.length - TAG_LEN);
const decipher = createDecipheriv('aes-256-gcm', kek, nonce);
decipher.setAAD(wrapAAD(aad));
decipher.setAuthTag(tag);
let dek: Buffer;
try {
dek = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch {
// Uniform error — never distinguish wrong-key from wrong-principal from
// corrupt-blob (that would be an oracle), and never surface a partial key.
throw new Error('sqlite/cek: unwrap DEK (wrong key, wrong principal, or corrupt blob)');
}
if (dek.length !== KEY_LEN) {
throw new Error(`sqlite/cek: unwrapped DEK has wrong length ${dek.length}`);
}
return dek;
}
@@ -41,9 +41,6 @@ export const CHAT_COLLECTION_SPECS: Record<string, CollectionSpec> = {
unique: ['messageId'],
index: ['conversationId', 'user', 'organization', 'parentMessageId', 'createdAt', 'expiredAt'],
dateFields: ['createdAt', 'updatedAt', 'expiredAt'],
// `_meiliIndex` is `select:false` upstream — an internal search-sync flag,
// never returned to clients.
deselected: ['_meiliIndex'],
},
// ---- Batch 2: self-contained chat documents (no tenant plugin) ----
@@ -155,9 +152,6 @@ export const CHAT_COLLECTION_SPECS: Record<string, CollectionSpec> = {
name: 'AgentApiKey',
index: ['userId', 'name', 'keyPrefix', 'expiresAt'],
dateFields: ['lastUsedAt', 'expiresAt', 'createdAt', 'updatedAt'],
// `keyHash` is `select:false` — the secret API-key hash. Queried by value
// (findOne({keyHash})) but never returned unless a `+keyHash` asks for it.
deselected: ['keyHash'],
},
Assistant: {
name: 'Assistant',
@@ -230,10 +224,6 @@ export const CHAT_COLLECTION_SPECS: Record<string, CollectionSpec> = {
unique: ['email', 'openidId', 'googleId', 'githubId'],
index: ['organization', 'provider', 'username', 'idOnTheSource', 'role'],
dateFields: ['createdAt', 'updatedAt', 'expiresAt'],
// `totpSecret` + `backupCodes` are `select:false` — 2FA secrets. Hidden on
// every read unless a `+totpSecret`/`+backupCodes` projection requests them
// (as TwoFactorAuthController does).
deselected: ['totpSecret', 'backupCodes'],
},
Session: {
name: 'Session',
@@ -1,129 +0,0 @@
import { createSqliteHandle, type SqliteHandle } from './index';
import type { DocModel } from './DocModel';
/**
* `select:false` secret exclusion (the #48 review gap): the SQLite store must NOT
* return schema-hidden fields on projection-less reads, matching the hiding
* mongoose does at the schema layer. Secrets: `User.totpSecret`/`backupCodes`,
* `AgentApiKey.keyHash`; internal flag: `Message._meiliIndex`. Native-driver spec
* → runs one-file-per-process (see test/ci.mjs).
*/
describe('DocModel — select:false deselection', () => {
let handle: SqliteHandle;
let User: DocModel;
let AgentApiKey: DocModel;
let Message: DocModel;
let SharedLink: DocModel;
beforeEach(() => {
handle = createSqliteHandle(['User', 'AgentApiKey', 'Message', 'SharedLink']);
User = handle.models.User;
AgentApiKey = handle.models.AgentApiKey;
Message = handle.models.Message;
SharedLink = handle.models.SharedLink;
});
afterEach(() => handle.close());
async function seedUser() {
return (await User.create({
email: 'a@b.co',
username: 'ab',
totpSecret: 'TOTP-SECRET-XYZ',
backupCodes: [{ codeHash: 'h1', used: false }],
})) as { _id: string };
}
it('projection-less findById / findOne / find OMIT totpSecret + backupCodes', async () => {
const u = await seedUser();
const byId = (await User.findById(u._id).lean()) as Record<string, unknown>;
expect(byId.email).toBe('a@b.co'); // normal field present
expect(byId).not.toHaveProperty('totpSecret');
expect(byId).not.toHaveProperty('backupCodes');
const one = (await User.findOne({ email: 'a@b.co' }).lean()) as Record<string, unknown>;
expect(one).not.toHaveProperty('totpSecret');
expect(one).not.toHaveProperty('backupCodes');
const many = (await User.find({ email: 'a@b.co' }).lean()) as Array<Record<string, unknown>>;
expect(many[0]).not.toHaveProperty('totpSecret');
expect(many[0]).not.toHaveProperty('backupCodes');
});
it('a `+field` projection re-includes ONLY the requested deselected field', async () => {
const u = await seedUser();
// Mirrors getUserById(id, '+totpSecret +backupCodes') and single-field asks.
const only = (await User.findById(u._id).select('+totpSecret').lean()) as Record<string, unknown>;
expect(only.totpSecret).toBe('TOTP-SECRET-XYZ');
expect(only).not.toHaveProperty('backupCodes'); // NOT requested → still hidden
expect(only.email).toBe('a@b.co'); // full doc otherwise
const both = (await User.findById(u._id).select('+totpSecret +backupCodes').lean()) as Record<
string,
unknown
>;
expect(both.totpSecret).toBe('TOTP-SECRET-XYZ');
expect(both.backupCodes).toBeDefined();
// Array form (mongoose accepts `['+totpSecret']`).
const arr = (await User.findById(u._id).select(['+totpSecret']).lean()) as Record<string, unknown>;
expect(arr.totpSecret).toBe('TOTP-SECRET-XYZ');
});
it('an inclusion projection of the field returns it; exclusion still hides it', async () => {
const u = await seedUser();
const incl = (await User.findById(u._id).select({ totpSecret: 1 }).lean()) as Record<string, unknown>;
expect(incl.totpSecret).toBe('TOTP-SECRET-XYZ');
expect(incl._id).toBeDefined();
expect(incl).not.toHaveProperty('email'); // pure inclusion
// Exclusion projection of an unrelated field must NOT re-expose the secret.
const excl = (await User.findById(u._id).select('-username').lean()) as Record<string, unknown>;
expect(excl).not.toHaveProperty('totpSecret');
expect(excl).not.toHaveProperty('username');
expect(excl.email).toBe('a@b.co');
});
it('AgentApiKey.keyHash: filterable by value, hidden in the result unless +keyHash', async () => {
await AgentApiKey.create({ userId: 'u1', name: 'k', keyHash: 'HASH-SECRET', keyPrefix: 'ak_abc' });
// The FILTER on keyHash still matches (deselection is a read-projection concern).
const found = (await AgentApiKey.findOne({ keyHash: 'HASH-SECRET' }).lean()) as Record<string, unknown>;
expect(found).toBeTruthy();
expect(found.keyPrefix).toBe('ak_abc');
expect(found).not.toHaveProperty('keyHash'); // secret not returned
const withHash = (await AgentApiKey.findOne({ keyHash: 'HASH-SECRET' })
.select('+keyHash')
.lean()) as Record<string, unknown>;
expect(withHash.keyHash).toBe('HASH-SECRET');
});
it('Message._meiliIndex is hidden by default, on reads AND aggregation, and in populate', async () => {
const m1 = (await Message.create({
messageId: 'm1',
conversationId: 'c1',
user: 'u1',
text: 'A',
_meiliIndex: true,
})) as { _id: string };
const read = (await Message.findOne({ messageId: 'm1' }).lean()) as Record<string, unknown>;
expect(read.text).toBe('A');
expect(read).not.toHaveProperty('_meiliIndex');
// Aggregation is fail-secure too (stricter than mongoose, which bypasses select:false).
const agg = await Message.aggregate([{ $match: { conversationId: 'c1' } }]);
expect(agg[0]).not.toHaveProperty('_meiliIndex');
// Populate: the populated Message sub-docs must also hide the internal flag.
await SharedLink.create({ shareId: 's1', conversationId: 'c1', user: 'u1', messages: [{ _id: m1._id }] });
const populated = (await SharedLink.findOne({ shareId: 's1' })
.populate({ path: 'messages' })
.lean()) as { messages: Array<Record<string, unknown>> };
expect(populated.messages[0].text).toBe('A');
expect(populated.messages[0]).not.toHaveProperty('_meiliIndex');
});
});
+30 -142
View File
@@ -533,156 +533,44 @@ function isEachSpec(v: unknown): boolean {
* Applies a Mongo string projection ('a b -c') or object projection.
* Inclusion projections always keep `_id` unless explicitly excluded.
*/
interface ParsedProjection {
mode: 'inclusion' | 'exclusion' | 'none';
/** Fields to project IN under inclusion mode: plain includes + `+` overrides. */
include: string[];
/** Fields to remove under exclusion mode. */
exclude: string[];
/** Whether `_id` survives (mongoose keeps it unless `{_id:0}` / `-_id`). */
includeId: boolean;
/** Fields explicitly requested (object `:1`, string bare, or `+field`) → bypass deselection. */
requested: Set<string>;
}
/**
* Parses a mongoose-shaped projection into inclusion/exclusion intent plus the
* set of explicitly-requested fields. Supports the `+field` string prefix, which
* mongoose uses to re-include a schema `select:false` (deselected) field WITHOUT
* turning the query into an inclusion projection — so `'+totpSecret'` yields the
* full document plus that one deselected field.
*/
function parseProjection(projection?: string | Record<string, 0 | 1>): ParsedProjection {
const plain: string[] = [];
const plus: string[] = [];
const exclude: string[] = [];
let includeId = true;
if (typeof projection === 'string') {
for (const raw of projection.split(/\s+/).filter(Boolean)) {
if (raw.startsWith('+')) {
plus.push(raw.slice(1));
} else if (raw.startsWith('-')) {
const f = raw.slice(1);
if (f === '_id') {
includeId = false; // `-_id` only drops _id; never triggers inclusion
} else {
exclude.push(f);
}
} else {
// A bare token (including `_id`) is an inclusion field: `.select('_id')`
// returns ONLY _id, matching mongoose.
plain.push(raw);
}
}
} else if (projection) {
for (const [k, v] of Object.entries(projection)) {
if (k === '_id') {
if (v === 0) {
includeId = false;
} else {
plain.push('_id'); // `{_id:1}` is an inclusion of only _id
}
} else if (v === 1) {
plain.push(k);
} else if (v === 0) {
exclude.push(k);
}
}
}
// A bare include (plain field or object `:1`) selects inclusion mode. A projection
// of only `-`/`+` (or object `:0`) is exclusion-from-the-full-document; a `+`
// override alone must NOT collapse the result to just that field, so it is
// exclusion mode with an empty exclude list.
const mode: ParsedProjection['mode'] = plain.length
? 'inclusion'
: exclude.length || plus.length
? 'exclusion'
: 'none';
return {
mode,
include: [...plain, ...plus],
exclude,
includeId,
requested: new Set([...plain, ...plus]),
};
}
/**
* Deep-copies the plain-object/array STRUCTURE of a value while passing Dates and
* any non-plain object through by reference. Used by projection: it must not
* mutate the source doc when deleting keys, but it also must not re-instantiate
* Dates (which `structuredClone`/JSON round-trips would), because a rebuilt Date
* loses `instanceof Date` across realms and breaks the engine's date comparisons.
*/
function cloneStructure(v: unknown): unknown {
if (v === null || typeof v !== 'object' || v instanceof Date) {
return v;
}
if (Array.isArray(v)) {
return v.map(cloneStructure);
}
// Only clone plain objects; anything exotic (Buffer, ObjectId, …) is shared.
const proto = Object.getPrototypeOf(v);
if (proto !== Object.prototype && proto !== null) {
return v;
}
const out: Doc = {};
for (const [k, val] of Object.entries(v as Doc)) {
out[k] = cloneStructure(val);
}
return out;
}
/**
* Projects a document per a mongoose-shaped `projection`, then enforces the
* collection's `deselected` fields (schema `select:false` — secrets like
* `totpSecret`/`keyHash`/`backupCodes` and internal flags like `_meiliIndex`):
* any deselected field NOT explicitly requested (via a `+field` override or an
* inclusion of it) is stripped on EVERY read. This closes the store-layer leak
* where a projection-less read returned schema-hidden secrets that mongoose omits.
*/
export function projectDoc(
doc: Doc,
projection?: string | Record<string, 0 | 1>,
deselected?: ReadonlySet<string>,
): Doc {
const enforceDeselect = deselected != null && deselected.size > 0;
if (!projection && !enforceDeselect) {
export function projectDoc(doc: Doc, projection?: string | Record<string, 0 | 1>): Doc {
if (!projection) {
return doc;
}
const p = parseProjection(projection);
let out: Doc;
if (p.mode === 'inclusion') {
out = {};
if (p.includeId && '_id' in doc) {
out._id = doc._id;
}
for (const k of p.include) {
if (k !== '_id' && hasPath(doc, k)) {
setPath(out, k, getPath(doc, k));
const spec: Record<string, 0 | 1> = {};
if (typeof projection === 'string') {
for (const raw of projection.split(/\s+/).filter(Boolean)) {
if (raw.startsWith('-')) {
spec[raw.slice(1)] = 0;
} else {
spec[raw] = 1;
}
}
} else {
// 'exclusion' or 'none' → start from a copy of the full document. Clone the
// STRUCTURE (so deleting a key never mutates the stored/source doc) while
// sharing Date (and other class) instances by reference — projection only
// deletes keys, never mutates values. `structuredClone` is avoided on purpose:
// it rebuilds Dates against Node's primordial realm, so the result would fail
// `instanceof Date` under a test/VM realm and break downstream date compares.
out = cloneStructure(doc);
for (const k of p.exclude) {
deletePath(out, k);
}
if (!p.includeId) {
deletePath(out, '_id');
}
Object.assign(spec, projection);
}
if (enforceDeselect) {
for (const f of deselected!) {
if (!p.requested.has(f)) {
deletePath(out, f);
const isInclusion = Object.values(spec).some((v) => v === 1);
if (isInclusion) {
const out: Doc = {};
if (spec._id !== 0 && '_id' in doc) {
out._id = doc._id;
}
for (const [k, v] of Object.entries(spec)) {
if (k !== '_id' && v === 1 && hasPath(doc, k)) {
setPath(out, k, getPath(doc, k));
}
}
return out;
}
// Exclusion projection
const out: Doc = structuredClone(doc);
for (const [k, v] of Object.entries(spec)) {
if (v === 0) {
deletePath(out, k);
}
}
return out;
}
@@ -14,20 +14,11 @@ import type Database from 'better-sqlite3-multiple-ciphers';
import { DocModel, type CollectionSpec } from './DocModel';
import { CHAT_COLLECTION_SPECS } from './collections';
import { ObjectId } from './engine';
import { keySqlcipher, loadOrCreateDEK, masterKeyFromEnv } from './keying';
export { DocModel, type CollectionSpec } from './DocModel';
export { CHAT_COLLECTION_SPECS } from './collections';
export { ObjectId } from './engine';
export { createDualWriteModel, DualWriteModel } from './DualWriteModel';
export {
CHAT_PRINCIPAL,
MASTER_KEY_ENV,
loadOrCreateDEK,
masterKeyFromEnv,
rewrapSidecar,
sidecarPath,
} from './keying';
export interface SqliteHandle {
models: Record<string, DocModel>;
@@ -39,17 +30,9 @@ export interface SqliteHandle {
/**
* Opens a SQLite database. Defaults to the path in `CHAT_SQLITE_PATH`, else an
* in-memory database (used by tests). WAL + NORMAL sync for durable throughput.
*
* Encryption at rest (SQLCipher AES-256, byte-compatible with the canonical Go
* driver `hanzoai/sqlite`) is driven by the rotation-safe ENVELOPE model — one
* canonical production path, no raw-key env seam:
* - `CHAT_SQLITE_MASTER_KEY` (64-hex = 32 bytes, sourced from KMS) is the KMS
* master key. Unset → the file is opened UNENCRYPTED (tests + local dev).
* - A per-file random DEK (the SQLCipher page key) lives WRAPPED under a KEK
* derived from the master key in a sidecar at `${dbPath}.dek`. On open the
* sidecar is unwrapped (or minted+written on first open); the raw DEK keys
* SQLCipher and is never persisted or logged. Rotating the master key rewraps
* the sidecar (DEK unchanged → pages untouched); see `rewrapSidecar`.
* A non-memory file is opened encrypted (SQLCipher AES-256, `hanzoai/sqlite`
* contract) when `CHAT_SQLITE_KEY` holds a 64-hex-char raw key; unset opens
* unencrypted (tests + local dev).
*/
export function openDatabase(dbPath?: string): Database.Database {
// `better-sqlite3-multiple-ciphers` is a native addon. The data-schemas index
@@ -61,11 +44,17 @@ export function openDatabase(dbPath?: string): Database.Database {
const path = dbPath ?? process.env.CHAT_SQLITE_PATH ?? ':memory:';
const db = new DatabaseCtor(path);
if (path !== ':memory:') {
// SQLCipher keying MUST precede any statement that touches DB pages. An
// in-memory database has no file at rest and no sidecar, so it is never keyed.
const masterKey = masterKeyFromEnv();
if (masterKey) {
keySqlcipher(db, loadOrCreateDEK(path, masterKey));
// SQLCipher keying MUST precede any statement that touches DB pages. Never
// log the key or the keyed path. KMS/CEK derivation is a later milestone;
// this only honors a raw key supplied out-of-band.
const key = process.env.CHAT_SQLITE_KEY;
if (key) {
if (!/^[0-9a-f]{64}$/i.test(key)) {
throw new Error('[sqlite-store] CHAT_SQLITE_KEY must be a 64-hex-char raw key');
}
db.pragma("cipher='sqlcipher'");
db.pragma('legacy=4');
db.pragma(`key="x'${key}'"`);
}
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA synchronous = NORMAL');
@@ -1,160 +0,0 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type Database from 'better-sqlite3-multiple-ciphers';
import { masterKeyFromEnv, MASTER_KEY_ENV, openDatabase, rewrapSidecar, sidecarPath } from './index';
/**
* Encryption-at-rest + envelope keying, exercised against the REAL native
* `better-sqlite3-multiple-ciphers` driver and the production `openDatabase`
* path. Native-driver spec → runs one-file-per-process (see test/ci.mjs).
*
* Byte-compatibility with the Go driver `hanzoai/sqlite` (real libsqlcipher) is
* proven out-of-band by the Go↔Node cross-open harness; here we prove the JS side
* is fail-secure end-to-end: real ciphertext on disk, wrong master rejected, and
* O(1) master-key rotation that leaves the encrypted pages untouched.
*/
const MASTER_A = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
const MASTER_B = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100';
const CANARY = 'PLAINTEXT_CANARY_c0ffee_hanzo_chat';
let dir: string;
const savedEnv = process.env[MASTER_KEY_ENV];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'chat-cek-'));
});
afterEach(() => {
if (savedEnv === undefined) {
delete process.env[MASTER_KEY_ENV];
} else {
process.env[MASTER_KEY_ENV] = savedEnv;
}
rmSync(dir, { recursive: true, force: true });
});
/** Opens via the production path under a given master key (or unencrypted). */
function open(dbPath: string, masterHex?: string): Database.Database {
if (masterHex === undefined) {
delete process.env[MASTER_KEY_ENV];
} else {
process.env[MASTER_KEY_ENV] = masterHex;
}
return openDatabase(dbPath);
}
function writeCanary(db: Database.Database): void {
db.exec('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT)');
db.prepare('INSERT INTO t (id, v) VALUES (1, ?)').run(CANARY);
db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); // flush WAL into the main file
}
function readCanary(db: Database.Database): string | undefined {
return (db.prepare('SELECT v FROM t WHERE id = 1').get() as { v: string } | undefined)?.v;
}
describe('masterKeyFromEnv', () => {
it('returns null when unset (unencrypted dev/test path)', () => {
delete process.env[MASTER_KEY_ENV];
expect(masterKeyFromEnv()).toBeNull();
});
it('throws on a malformed key — never a silent plaintext fallback', () => {
process.env[MASTER_KEY_ENV] = 'not-hex';
expect(() => masterKeyFromEnv()).toThrow(/64-hex/);
process.env[MASTER_KEY_ENV] = 'abc'; // too short
expect(() => masterKeyFromEnv()).toThrow(/64-hex/);
});
it('returns a 32-byte buffer for a valid 64-hex key', () => {
process.env[MASTER_KEY_ENV] = MASTER_A;
expect(masterKeyFromEnv()?.length).toBe(32);
});
});
describe('openDatabase — encryption at rest (envelope)', () => {
it('writes real ciphertext, mints a 0600 sidecar, and reopens', () => {
const dbPath = join(dir, 'chat.db');
let db = open(dbPath, MASTER_A);
writeCanary(db);
db.close();
// (a) sidecar: exists, exactly version(1)+nonce(12)+ct(32)+tag(16)=61 bytes,
// and NOT readable by group/other.
const side = sidecarPath(dbPath);
expect(existsSync(side)).toBe(true);
expect(statSync(side).size).toBe(61);
expect(statSync(side).mode & 0o077).toBe(0); // no group/other access (0600)
// (b) at rest: the plaintext canary is absent and the header is a random
// SQLCipher salt, NOT the "SQLite format 3\0" magic.
const raw = readFileSync(dbPath);
expect(raw.includes(Buffer.from(CANARY))).toBe(false);
expect(raw.subarray(0, 16).toString('latin1')).not.toContain('SQLite format 3');
// (c) reopen with the SAME master → row is readable (DEK recovered from sidecar).
db = open(dbPath, MASTER_A);
expect(readCanary(db)).toBe(CANARY);
db.close();
});
it('rejects the WRONG master at the envelope (fail-secure, no partial key)', () => {
const dbPath = join(dir, 'chat.db');
const db = open(dbPath, MASTER_A);
writeCanary(db);
db.close();
// Wrong master → sidecar unwrap fails the GCM tag → throws before SQLCipher.
expect(() => open(dbPath, MASTER_B)).toThrow(/wrong key, wrong principal, or corrupt blob/);
});
it('unset master opens UNENCRYPTED (no sidecar, plaintext on disk)', () => {
const dbPath = join(dir, 'plain.db');
const db = open(dbPath, undefined);
writeCanary(db);
db.close();
expect(existsSync(sidecarPath(dbPath))).toBe(false);
const raw = readFileSync(dbPath);
expect(raw.subarray(0, 16).toString('latin1')).toContain('SQLite format 3');
expect(raw.includes(Buffer.from(CANARY))).toBe(true); // plaintext, as documented
});
});
describe('rewrapSidecar — O(1) master-key rotation (pages untouched)', () => {
it('rotates old→new: new master reads, old master no longer opens, data intact', () => {
const dbPath = join(dir, 'chat.db');
let db = open(dbPath, MASTER_A);
writeCanary(db);
db.close();
const before = readFileSync(dbPath); // ciphertext pages snapshot
rewrapSidecar(dbPath, Buffer.from(MASTER_A, 'hex'), Buffer.from(MASTER_B, 'hex'));
// The encrypted PAGES are byte-identical — only the sidecar changed (DEK same).
expect(readFileSync(dbPath).equals(before)).toBe(true);
// New master opens and reads the pre-rotation data.
db = open(dbPath, MASTER_B);
expect(readCanary(db)).toBe(CANARY);
db.close();
// Old master is now rejected.
expect(() => open(dbPath, MASTER_A)).toThrow(/wrong key, wrong principal, or corrupt blob/);
});
it('a wrong old-master rewrap throws and leaves the sidecar intact', () => {
const dbPath = join(dir, 'chat.db');
const db = open(dbPath, MASTER_A);
writeCanary(db);
db.close();
const sidecarBefore = readFileSync(sidecarPath(dbPath));
expect(() =>
rewrapSidecar(dbPath, Buffer.from(MASTER_B, 'hex'), Buffer.from(MASTER_A, 'hex')),
).toThrow(/wrong key, wrong principal, or corrupt blob/);
// Sidecar untouched → the real master still opens the DB.
expect(readFileSync(sidecarPath(dbPath)).equals(sidecarBefore)).toBe(true);
const db2 = open(dbPath, MASTER_A);
expect(readCanary(db2)).toBe(CANARY);
db2.close();
});
});
@@ -1,134 +0,0 @@
/**
* Envelope keying lifecycle for the SQLite store — the seam between the pure
* crypto in `cek.ts` and the on-disk SQLCipher database.
*
* Chat is ONE shared database file with row-level tenant isolation (not per-org
* files), so it keys under a single fixed principal: PrincipalGlobal / "hanzo-chat".
* The DEK for that file lives wrapped in a sidecar at `${dbPath}.dek`; the raw DEK
* is never written to disk and never logged.
*
* SQLCipher parameter contract (load-bearing — cross-open with `hanzoai/sqlite`):
* `hanzoai/sqlite` links the REAL libsqlcipher, which implements exactly one
* cipher (SQLCipher) at its v4 defaults (PBKDF2-HMAC-SHA512 ×256000, HMAC-SHA512,
* 4096-byte pages, 16-byte salt in the file header, no plaintext header) and takes
* a raw key via `key=x'HEX'` — no `cipher=` pragma, because there is only one
* cipher. `better-sqlite3-multiple-ciphers` (SQLite3MultipleCiphers) supports MANY
* ciphers, so it must be told to emulate that exact format:
* PRAGMA cipher='sqlcipher'; -- select the SQLCipher scheme
* PRAGMA legacy=4; -- emulate SQLCipher *version 4* on-disk format
* PRAGMA key="x'HEX'"; -- 32-byte raw key => skip the passphrase KDF
* `legacy=4` here means "byte-compatible with SQLCipher v4" (SQLite3MC's term for
* on-disk-format-version, NOT "deprecated"); `legacy=0` would be SQLite3MC's own
* non-SQLCipher variant, which real libsqlcipher cannot read. Proven byte-compat
* by the Go↔Node cross-open test.
*/
import {
closeSync,
existsSync,
fsyncSync,
openSync,
readFileSync,
renameSync,
writeSync,
} from 'node:fs';
import { randomBytes } from 'node:crypto';
import { PrincipalGlobal, deriveKEK, newDEK, principalAAD, unwrapDEK, wrapDEK } from './cek';
/** The single principal chat's shared database keys under. */
export const CHAT_PRINCIPAL = { type: PrincipalGlobal, id: 'hanzo-chat' } as const;
/** Env var holding the 32-byte (64-hex) KMS master key. Unset => unencrypted. */
export const MASTER_KEY_ENV = 'CHAT_SQLITE_MASTER_KEY';
/** Minimal structural view of the native handle — just the `pragma` sink. */
interface Keyable {
pragma(source: string): unknown;
}
/**
* Reads and validates the master key from `CHAT_SQLITE_MASTER_KEY`. Returns null
* when unset (tests / local dev open unencrypted). A malformed value is a hard
* error — never a silent fallback to plaintext.
*/
export function masterKeyFromEnv(): Buffer | null {
const hex = process.env[MASTER_KEY_ENV];
if (!hex) {
return null;
}
if (!/^[0-9a-f]{64}$/i.test(hex)) {
throw new Error(`[sqlite-store] ${MASTER_KEY_ENV} must be a 64-hex-char (32-byte) master key`);
}
return Buffer.from(hex, 'hex');
}
/** Sidecar path for a database file: the wrapped-DEK blob lives beside it. */
export function sidecarPath(dbPath: string): string {
return `${dbPath}.dek`;
}
/** The chat principal's GCM binding context (= HKDF info; one encoding, DRY). */
function chatAAD(): Buffer {
return principalAAD(CHAT_PRINCIPAL.type, CHAT_PRINCIPAL.id);
}
/**
* Writes the sidecar atomically: a private tmp file (mode 0600) is fsync'd then
* renamed over the target, so a crash mid-write can never leave a truncated or
* partially-visible sidecar (a corrupt sidecar would brick the database). `wx`
* fails if the tmp name already exists (no clobber of a concurrent writer's tmp).
*/
function writeSidecarAtomic(path: string, blob: Buffer): void {
const tmp = `${path}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
const fd = openSync(tmp, 'wx', 0o600);
try {
writeSync(fd, blob);
fsyncSync(fd);
} finally {
closeSync(fd);
}
renameSync(tmp, path);
}
/**
* Resolves the raw SQLCipher DEK for a database file under `masterKey`:
* - sidecar exists → unwrap it under the KEK (THROWS on tamper / wrong master)
* - sidecar absent → mint a fresh DEK, wrap it under the KEK, write the sidecar
* The returned DEK is the page key; never log it.
*/
export function loadOrCreateDEK(dbPath: string, masterKey: Buffer): Buffer {
const kek = deriveKEK(masterKey, CHAT_PRINCIPAL.type, CHAT_PRINCIPAL.id);
const aad = chatAAD();
const path = sidecarPath(dbPath);
if (existsSync(path)) {
return unwrapDEK(kek, readFileSync(path), aad);
}
const dek = newDEK();
writeSidecarAtomic(path, wrapDEK(kek, dek, aad));
return dek;
}
/**
* Rotates the master key WITHOUT touching the encrypted pages: unwrap the DEK
* under the OLD master's KEK, rewrap under the NEW master's KEK, atomically
* replace the sidecar. The DEK — and therefore every ciphertext page — is
* unchanged, so this is O(1) and cannot brick the file. A wrong `oldMaster` (or a
* tampered sidecar) THROWS from the unwrap and leaves the sidecar intact.
*/
export function rewrapSidecar(dbPath: string, oldMaster: Buffer, newMaster: Buffer): void {
const aad = chatAAD();
const oldKEK = deriveKEK(oldMaster, CHAT_PRINCIPAL.type, CHAT_PRINCIPAL.id);
const dek = unwrapDEK(oldKEK, readFileSync(sidecarPath(dbPath)), aad);
const newKEK = deriveKEK(newMaster, CHAT_PRINCIPAL.type, CHAT_PRINCIPAL.id);
writeSidecarAtomic(sidecarPath(dbPath), wrapDEK(newKEK, dek, aad));
}
/**
* Keys a `better-sqlite3-multiple-ciphers` handle with the raw DEK in
* SQLCipher-4-compatible RAW-key format (see file header). MUST run before any
* page-touching statement. Never log the DEK or the pragma text.
*/
export function keySqlcipher(db: Keyable, dek: Buffer): void {
db.pragma("cipher='sqlcipher'");
db.pragma('legacy=4');
db.pragma(`key="x'${dek.toString('hex')}'"`);
}
-2
View File
@@ -21,8 +21,6 @@ const JEST = join(dirname(require.resolve('jest/package.json')), 'bin', 'jest.js
/** Native-driver specs — each run in its own process. */
const ISOLATED = [
'src/stores/sqlite/DocModel.spec.ts',
'src/stores/sqlite/deselect.spec.ts',
'src/stores/sqlite/keying.spec.ts',
'src/stores/sqlite/DualWriteModel.spec.ts',
'src/models/storeRegistry.spec.ts',
'src/models/plugins/tenantIsolation.coverage.spec.ts',