Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9fd1cfc07 | ||
|
|
5feca644e2 | ||
|
|
72a5c71fe3 |
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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')}'"`);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user