diff --git a/.changeset/25020-turso-encryption-key-local.md b/.changeset/25020-turso-encryption-key-local.md new file mode 100644 index 00000000000..fcfebd168fd --- /dev/null +++ b/.changeset/25020-turso-encryption-key-local.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-turso': minor +'@objectstack/service-datasource': patch +--- + +fix(spec,driver-turso)!: a turso datasource that declares `encryptionKey` on a local file or an embedded replica is refused where it is written and when the driver is built, instead of being accepted and written in plaintext + +Clause-②: no (narrowing) — the accept set of the `turso` `datasource.config` contract, of the published `TursoConfigSchema` mirror and of `new TursoDriver()` narrows by one key beside a `file:` url or `:memory:`, in every mode. No key is added, removed or renamed, and no exported symbol moves. `@objectstack/service-datasource` is graded `patch`: the TSDoc of its exported `TursoDriverConfigInput.encryptionKey` no longer promises an encrypted file, and nothing else in it changes. + +`encryptionKey` was declared as an "AES-256 encryption key for the local database file", and nothing applied it. The turso driver reads and writes a local or replica file through better-sqlite3, which has no encryption codec (`PRAGMA key` is a silent no-op there). Measured on the driver source with a test key: a local datasource with `encryptionKey` constructed, connected and wrote its rows, and plain better-sqlite3, opening the file read-only with no key, read them back. An embedded replica fared no better: only its sync client was handed the key, after the local engine had already written the file in plaintext, and a file that client encrypted is one the local engine cannot open. An operator who declared the key got an unencrypted file and no signal. + +**BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). A non-empty `encryptionKey` on a local datasource (a `file:` url or `:memory:`) or an embedded replica (a `file:` url beside `syncUrl`), detected or forced with `mode`, is refused now, at both doors together, with one message per mode that names the mode and what does encrypt. The key is judged by where the database lives, not by the mode: the same `file:` url or `:memory:` under a forced `mode: 'remote'` is refused too, in its own words, because the driver hands that url to the libSQL client it builds, which is handed no key, so the file was written in plaintext all the same: + +- **at authoring**, as one `custom` issue on `encryptionKey` (`config.encryptionKey` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service; +- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any database file is opened. + +For a local or replica datasource the message is the same text at all three places; for a `file:` url or `:memory:` under a forced `mode: 'remote'`, the schema and the constructor give the forced-remote text, while the driver mirror, which declares no `mode` and strips it, refuses the same config in the local text. A test holds the constructor's copy equal to the schema's issue byte for byte in every mode, and the mirror's verdict and words equal to the schema's wherever no `mode` is forced. It comes last among the local-engine refusals: a url the engine cannot open, a lone `sync` and a forced `mode` the driver would ignore each keep their own message. An empty `encryptionKey` is unset and is still accepted. A remote url (`libsql://`, `https://`, `ws(s)://`) is not judged by this refusal. The `encryptionKey` describe texts no longer promise an encrypted file. + +### Migration: FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `url: 'file:./data/app.db', encryptionKey: '…'` | drop `encryptionKey`; put the file on an encrypted disk or volume if it must be encrypted at rest | +| `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io', encryptionKey: '…'` | drop `encryptionKey`, the same way | +| `url: 'file:./data/app.db', mode: 'remote', encryptionKey: '…'` (or `url: ':memory:'`) | drop `encryptionKey`, the same way | +| either, where the data must be encrypted at rest and no encrypted volume is available | a remote Turso database (`url: 'libsql://my-db.turso.io'`), whose storage encryption is the server's to configure, not this key's | + +The fix in one line: delete `encryptionKey` from every turso datasource whose `url` is a `file:` url or `:memory:`. + +A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical. Otherwise it is left unconnected with a warning. Before this change the same row booted and wrote its file in plaintext. + +Blast radius, measured on this tree: no example, template, CLI scaffold or published skill authors the key, and no host default or environment variable sets it (a turso `encryptionKey` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. + + diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 78084c8a2a1..fca23ec72a1 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -195,7 +195,7 @@ ObjectStack — is what makes them secrets: | `mongodb` | `options.key` | TLS private key material (PEM) | | `mongodb` | `options.passphrase` | TLS key passphrase | | `mongodb` | `options.authMechanismProperties.AWS_SESSION_TOKEN` | AWS STS session token (`MONGODB-AWS` auth) | -| `turso` \| `libsql` | `encryptionKey` | AES-256 key for the local database file | +| `turso` \| `libsql` | `encryptionKey` | A key the driver never applies (see below) | Unlike `password` / `authToken` (typed `never` in their schemas — the parse refuses them outright), these six keys are **writable**: the parse accepts @@ -222,15 +222,28 @@ like the rest of this table. **This is a deliberate, documented trade-off ([#9124](https://github.com/objectstack-ai/objectstack/issues/9124)), not an oversight.** The binder has exactly one named slot. Refusing `proxyPassword`, -`tlsCertificateKeyFilePassword`, `key`, `passphrase`, or turso's -`encryptionKey` at write time would remove the only way to configure an -authenticated SOCKS5 proxy, a passphrase-protected TLS key, or a -locally-encrypted database file — a capability the client genuinely honours, +`tlsCertificateKeyFilePassword`, `key` or `passphrase` at write time would +remove the only way to configure an authenticated SOCKS5 proxy or a +passphrase-protected TLS key — a capability the client genuinely honours, with no working refusal remedy. (`AWS_SESSION_TOKEN` isn't part of that trade-off — see above.) **Restart condition:** the first real deployment that needs an authenticated proxy or a passphrase-protected key converts that group into named binder-slot support — one mechanism covering those -five keys — rather than the current per-key accept-and-document posture. +four keys — rather than the current per-key accept-and-document posture. + +Turso's `encryptionKey` is no longer part of that trade-off either. It was +declared as an AES-256 key for the local database file, but the driver reads +and writes that file through better-sqlite3, which has no encryption codec, so +the file was written in plaintext whatever the key said. A local or +embedded-replica turso datasource that declares it is now refused at write +time and when the driver is built: drop the key, and put the file on an +encrypted disk or volume if it must be encrypted at rest. A remote turso +datasource (a `libsql://`, `https://` or `ws(s)://` url) still parses with the +key, which the driver does not forward (a remote database's storage encryption +is the server's), while a `file:` url or `:memory:` under a forced +`mode: 'remote'` is refused like a local one, because the libSQL client the +driver builds for it is handed no key; a stored value is redacted on read like +the rest of this table. ## Startup: a driver that cannot connect aborts the boot diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index f71192c4a48..57f9ef558e8 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -68,7 +68,7 @@ Turso / libSQL Connection Configuration | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a local file written as a file: URL (never a bare path), or :memory: | | **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | -| **encryptionKey** | `string` | optional | AES-256 encryption key for the local database file (local/replica modes) | +| **encryptionKey** | `string` | optional | Not honoured in any mode: refused for a local or replica datasource, whose SQLite engine cannot encrypt the file, and for a file: url or :memory: under a forced remote mode, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key | | **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | | **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint | | **sync** | `{ intervalSeconds?: integer; onConnect?: boolean }` | optional | Embedded-replica sync configuration (requires `syncUrl`) | diff --git a/docs/design/driver-turso.md b/docs/design/driver-turso.md index 69ed82c43f9..c3aa5725191 100644 --- a/docs/design/driver-turso.md +++ b/docs/design/driver-turso.md @@ -523,7 +523,7 @@ carries the prescription. The live keys: |:---|:---|:---:|:---| | `url` | `string` | (required) | Database URL (`libsql://`, `https://`, `file:`, `:memory:`) — in replica mode, also the local file | | `authToken` | `string?` | — | JWT auth token for remote databases | -| `encryptionKey` | `string?` | — | AES-256 encryption key for local files | +| `encryptionKey` | `string?` | — | Encrypts nothing, in any mode: refused for a local or replica datasource (`VALIDATION_ERROR` / 400 at construction, a `custom` issue at authoring), because the local engine (better-sqlite3) has no encryption codec, and for a `file:` url or `:memory:` under a forced `mode: 'remote'`, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key | | `concurrency` | `number` | `20` | Maximum concurrent requests | | `syncUrl` | `string?` | — | Remote sync URL for embedded replica mode | | `sync.intervalSeconds` | `number` | `60` | Periodic sync interval (0 = manual only) | diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index a2341c680d9..27b6117e715 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -1716,7 +1716,7 @@ ], "knownGaps": [ "The legacy-alias clause tests a row shape the CURRENT parse refuses to create — a stored row from before the aliases moved to `guidance`. It is only reachable by writing the row directly (⚠️ NOT through the runtime wizard: POST /api/v1/datasources refuses an alias-spelled config key 400 DATASOURCE_ADMIN_ERROR — the aliases moved to `guidance`, which refuses them, #8078 — measured on a stock showcase boot with `passwd` and by QA run #21056. The run planted its rows with a direct sys_metadata write while the server was STOPPED, then booted and read through both doors). If the run cannot write such a row, that clause is blocked(fixture) and MUST be recorded — a redaction that has never been tested against the shape it exists for is an untested redaction.", - "`encryptionKey` (turso) is deliberately STILL WRITABLE while being redacted on read (#8081 scope item 4). Scoring it as a write refusal is a false fail; the correct expectation is write-accepted + read-redacted.", + "`encryptionKey` (turso) is deliberately STILL WRITABLE on a REMOTE turso datasource while being redacted on read (#8081 scope item 4). Scoring it as a write refusal there is a false fail; the correct expectation is write-accepted + read-redacted. On a local or embedded-replica turso datasource (a file: url, with or without syncUrl) the write IS refused since #25020, on config.encryptionKey: the driver cannot encrypt that file, so that refusal is the correct expectation, not a false fail.", "acceptance[1] (the credential-bound datasource actually connects) and acceptance[6] (judged by a connect probe after the echoed-config round-trip) need a reachable, credential-protected database of a SHIPPED driver (postgres, mysql or mongodb — the driver id since 18; an authored `mongo` is refused by name, packages/spec/src/data/datasource.zod.ts). Stock showcase provides none, but the QA container has a PostgreSQL 16 install (/usr/lib/postgresql/16/bin, with a `postgres` system user), and QA run #24575 proved a recipe on it that scored both clauses pass (#24615 reused it): as `postgres`, `initdb -A scram-sha-256` into a scratch data directory, then `pg_ctl … -o '-p PORT -k SOCKETDIR -c listen_addresses=127.0.0.1' start`; create a login role WITH a password and a database it owns; bind it through POST /api/v1/datasources with the password in the top-level `secret`, never in `config`, so the door binds external.credentialsRef. Every parent directory of the data and socket directories must be traversable by `postgres`. Runs before #24575 recorded both clauses blocked(environment) (#21784, #21845). CI has postgres and mysql only inside the Temporal Conformance (live PG + MySQL) job (.github/workflows/ci.yml services postgres:16 and mysql:8.0), which runs no checklist item, and has no mongodb anywhere. ⛔ Never score either clause from a successful publish alone, which proves the paved path is accepted, not that it connects. A run that cannot start the database still reads the stored-credential half of acceptance[6] (after the PATCH, the admin door still reports hasSecret and the bound secret handle is unchanged); record that as a partial reading, separate from the connect half", "the plugin-driver boundary is RULED (#21921, docs note in content/docs/data-modeling/drivers.mdx): for a driver the platform ships no contract for, the platform neither validates config nor guesses which of its keys are credentials. Keeping credentials out of a plugin driver's config, through external.credentialsRef, is the plugin author's responsibility. Only the platform's FIXED credential spellings are redacted there by name (acceptance[5]). A credential under any other key of a plugin driver's config being stored and served to administrators as written is that boundary, never a finding", "no datasource read door serves the mask literal (packages/spec/src/data/secret-mask.ts#SECRET_MASK is read by the encrypted-field read path and the settings REST boundary, not here): the admin door OMITS each withheld credential key and names it in redactedConfigKeys beside hasSecret, and the metadata door serves the external.credentialsRef handle. So the round-trip acceptance[6] drives carries no mask, and what it exercises is the carry-forward of the withheld keys on PATCH (packages/services/service-datasource/src/datasource-config-redaction.ts#restoreRedactedConfig). QA run #24575 also measured a hand-written literal mask: PATCH /api/v1/datasources/NAME with `secret` set to it is accepted 200 and rewraps the stored secret to the mask (the reconnect then fails), and `config.encryptionKey` set to it overwrites the stored key. No read door serves that string, so it is not the echoed case acceptance[6] and negative[5] score; it is held for the maintainer on #24579 §7, where no contract declares it. ⛔ Score it under neither" @@ -1730,7 +1730,7 @@ "read the SAME datasource back through the metadata door (GET /api/v1/meta/datasource/) — a different code path with its own redaction hook", "plant a stored row carrying a legacy alias spelling (passwd / pwd / token / jwt / auth_token / authtoken) and read it back through both doors", "author a datasource for a driver the platform ships no contract for, carrying a canonically-spelled credential key (password or authToken) AND one non-canonical credential-looking key (for example apiSecret), and read it back. The second key observes the ruled boundary; it is not a redaction target", - "for turso specifically, write `encryptionKey`, then read it back through both doors", + "for turso specifically, write `encryptionKey` on a remote (libsql:// or https://) turso datasource, then read it back through both doors", "grep the full response bodies of every read above for the planted cleartext values, and of the third read door, GET /api/v1/data/sys_metadata?type=datasource (admin only), which serves the stored row and also redacts (QA run #24575)" ], "acceptance": [ diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index cd21e141ad3..b45ab7e111f 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -250,6 +250,17 @@ that nothing would honour, each with the message `@objectstack/spec`'s would be ignored. Drop `mode` for an embedded replica, or drop `syncUrl` (and `sync`) for a plain local database. +It refuses (`VALIDATION_ERROR` / 400) a non-empty `encryptionKey` on a local +database or an embedded replica too, in the same message the spec contract +gives at authoring. The driver reads and writes that file through +better-sqlite3, which has no encryption codec, so the key would encrypt nothing +and the file would be written in plaintext that any SQLite reader opens without +it. Drop `encryptionKey`, and put the file on an encrypted disk or volume if it +must be encrypted at rest, or use a remote Turso database, whose storage +encryption is the server's to configure. A `file:` url or `:memory:` under a +forced `mode: 'remote'` is refused the same way: the libSQL client the driver +builds for that url is handed no key. + You can also force a specific mode: ```typescript @@ -327,8 +338,9 @@ interface TursoDriverConfig { authToken?: string; /** - * AES-256 encryption key for local database file. - * Only effective in local/replica modes. + * Encrypts nothing, in any mode: refused in local and replica modes + * (VALIDATION_ERROR / 400), because the local engine cannot encrypt the file, + * and beside a file: url or :memory: under a forced mode: 'remote'. */ encryptionKey?: string; diff --git a/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts b/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts index 20132e15544..7b4ef196a37 100644 --- a/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts +++ b/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts @@ -33,7 +33,18 @@ * everywhere, as a replica that never synced. [#20586] And for a forced * `mode: 'local'` beside a `syncUrl`, refused on `mode` too — the fourth * copy. That row used to be accepted everywhere as well, as a "local" - * database the driver synced with the remote anyway. + * database the driver synced with the remote anyway. [#25020] And for a + * non-empty `encryptionKey` in a local or replica mode, refused on + * `encryptionKey` — the fifth copy, one text per mode. Those rows used to + * be accepted everywhere, and the driver wrote the file in plaintext: its + * local engine (better-sqlite3) has no encryption codec. And for the same + * key beside a `file:` url or `:memory:` under a forced `mode: 'remote'`, + * whose libSQL client is handed no key — the sixth copy, one text for a + * file and one for an in-memory database. The mirror declares no `mode`, so + * it judges those configs as the local database their url selects (it + * refuses them on `encryptionKey` in the local arm's words); its own copy + * of the remote arm is unreachable through it and kept byte-identical, as + * its other forced-mode branches are. * * ⚠️ The mirror declares no `mode`, so zod strips an authored one before its * refinement runs: rows that FORCE a mode are judged by the constructor and the @@ -65,6 +76,8 @@ afterAll(() => rmSync(DIR, { recursive: true, force: true })); const FILE = `file:${DIR}/app.db`; const REMOTE = 'libsql://db.example.turso.io'; +/** A test value only — never a real key. */ +const KEY = 'test-key-25020'; type Authored = { url: string; @@ -72,6 +85,7 @@ type Authored = { sync?: { intervalSeconds?: number; onConnect?: boolean }; mode?: 'local' | 'replica' | 'remote'; timeoutMs?: number; + encryptionKey?: string; }; interface Row { @@ -80,7 +94,7 @@ interface Row { /** The constructor's verdict on this config. */ ctor: 'accept' | 'refuse'; /** The key both schemas refuse on, or `undefined` when they accept. */ - refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode'; + refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode' | 'encryptionKey'; /** How many issues the spec contract raises on a refused row; 1 unless stated. */ issues?: number; /** The constructor accepts it and ignores a key: refused at authoring only. None today (#20200). */ @@ -111,6 +125,15 @@ const ROWS: Row[] = [ { name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' }, { name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' }, { name: "a bare path under a forced mode: 'remote' (the client refuses it at connect)", config: { url: './data/app.db', mode: 'remote' }, ctor: 'accept' }, + // [#25020] An empty key is unset, as the loaders read it. + { name: 'an empty encryptionKey (unset) beside a file: url', config: { url: FILE, encryptionKey: '' }, ctor: 'accept' }, + { name: 'an empty encryptionKey (unset) on a replica', config: { url: FILE, syncUrl: REMOTE, sync: { onConnect: false }, encryptionKey: '' }, ctor: 'accept' }, + // [#25020, patch round 1] The forced-remote local-url arm's controls: no key, an empty key, and a + // remote url with a key — a remote server's storage, which this card does not judge. + { name: ":memory: under a forced mode: 'remote'", config: { url: ':memory:', mode: 'remote' }, ctor: 'accept' }, + { name: "an empty encryptionKey (unset) beside file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', encryptionKey: '' }, ctor: 'accept' }, + { name: 'encryptionKey beside a libsql:// url (remote: not judged by this card)', config: { url: REMOTE, encryptionKey: KEY }, ctor: 'accept' }, + { name: "encryptionKey beside libsql:// under a forced mode: 'remote' (not judged by this card)", config: { url: REMOTE, mode: 'remote', encryptionKey: KEY }, ctor: 'accept' }, // ── a remote url in a local or replica mode ───────────────────────────── { name: 'libsql:// beside syncUrl', config: { url: REMOTE, syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, @@ -181,15 +204,50 @@ const ROWS: Row[] = [ { name: "file: + a wss:// syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: 'wss://db.example.turso.io' }, ctor: 'refuse', refusedOn: 'mode' }, { name: ":memory: + syncUrl under a forced mode: 'local'", config: { url: ':memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, { name: "file::memory: + syncUrl under a forced mode: 'local'", config: { url: 'file::memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, + + // ── an encryptionKey the local engine cannot apply: refused on `encryptionKey` (#25020) ── + // Every row here was accepted everywhere until #25020, and the driver wrote the file in plaintext. + { name: 'encryptionKey beside a file: url', config: { url: FILE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: 'encryptionKey beside an uppercase FILE: url', config: { url: `FILE:${DIR}/upper-key.db`, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: 'encryptionKey beside a file: url behind whitespace', config: { url: ` ${FILE}`, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: 'encryptionKey beside :memory:', config: { url: ':memory:', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: "encryptionKey beside file: under a forced mode: 'local'", config: { url: FILE, mode: 'local', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: 'encryptionKey on a replica: file: + syncUrl', config: { url: FILE, syncUrl: REMOTE, sync: { onConnect: false }, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: 'encryptionKey on a replica syncing from wss://, bounded by timeoutMs', config: { url: FILE, syncUrl: 'wss://db.example.turso.io', timeoutMs: 5000, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: "encryptionKey on file: + syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + // ORDER: it comes last — every refusal above keeps its own row's verdict beside a key. + { name: 'encryptionKey beside a bare path: the url refusal first', config: { url: './data/app.db', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'encryptionKey on a remote url beside syncUrl: the url refusal first', config: { url: REMOTE, syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'encryptionKey on :memory: beside syncUrl: the in-memory replica refusal first', config: { url: ':memory:', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'url' }, + // The spec contract raises BOTH issues here (`sync`, then `encryptionKey`); the constructor throws the first. + { name: 'encryptionKey beside sync with no syncUrl: the sync refusal first', config: { url: FILE, sync: { intervalSeconds: 60 }, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'sync', issues: 2 }, + { name: "encryptionKey under a forced mode: 'replica' with no syncUrl: the mode refusal first", config: { url: FILE, mode: 'replica', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "encryptionKey + syncUrl under a forced mode: 'local': the mode refusal first", config: { url: FILE, mode: 'local', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'mode' }, + + // ── the same key beside a local url under a forced mode: 'remote' (#25020, patch round 1) ── + // Judged by where the database lives, not by the mode: `connect()` hands the url to the libSQL + // client the driver builds, which is handed no key. The mirror declares no `mode`, so these rows + // are judged by the constructor and the spec contract only (see the header). + { name: "encryptionKey beside file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: "encryptionKey beside an uppercase FILE: url under a forced mode: 'remote'", config: { url: `FILE:${DIR}/upper-remote-key.db`, mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: "encryptionKey beside file: + timeoutMs under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', timeoutMs: 5000, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: "encryptionKey beside :memory: under a forced mode: 'remote'", config: { url: ':memory:', mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + { name: "encryptionKey beside file::memory: under a forced mode: 'remote'", config: { url: 'file::memory:', mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' }, + // ORDER against the forced-remote refusals: the spec contract raises BOTH issues (theirs first); the constructor throws the first. + { name: "encryptionKey + syncUrl beside file: under a forced mode: 'remote': the syncUrl refusal first", config: { url: FILE, mode: 'remote', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'syncUrl', issues: 2 }, + { name: "encryptionKey + sync with no syncUrl beside file: under a forced mode: 'remote': the sync refusal first", config: { url: FILE, mode: 'remote', sync: { onConnect: true }, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'sync', issues: 2 }, ]; /** * The rows the constructor refuses on a sync key, on a forced replica with no - * `syncUrl`, or on a forced local mode beside a `syncUrl`: its message is a - * copy of the spec's (#20200, #20437, #20586). + * `syncUrl`, on a forced local mode beside a `syncUrl`, or on an + * `encryptionKey` in a local or replica mode: its message is a copy of the + * spec's (#20200, #20437, #20586, #25020). */ const SYNC_KEY_REFUSALS = ROWS.filter( - (r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'), + (r) => + r.ctor === 'refuse' && + (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode' || r.refusedOn === 'encryptionKey'), ); /** @@ -197,16 +255,18 @@ const SYNC_KEY_REFUSALS = ROWS.filter( * reading `buildTursoDriverConfig` / `resolveTursoUrl` in * `@objectstack/service-datasource` perform (not a dependency of this package, * hence spelled here): the url TRIMMED, an empty `syncUrl` unset, and the - * authored `timeoutMs` landing on the driver's `timeout`. + * authored `timeoutMs` landing on the driver's `timeout`, and an empty + * `encryptionKey` unset. */ function driverConfigOf(authored: Authored): TursoDriverConfig { - const { url, syncUrl, sync, mode, timeoutMs } = authored; + const { url, syncUrl, sync, mode, timeoutMs, encryptionKey } = authored; return { url: url.trim(), ...(syncUrl ? { syncUrl } : {}), ...(sync ? { sync } : {}), ...(mode ? { mode } : {}), ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}), + ...(encryptionKey ? { encryptionKey } : {}), }; } @@ -249,7 +309,13 @@ describe('turso config: the constructor, the spec contract and this mirror agree // [#20586] The forced-local half of the `mode` rows, floored on its own so // it cannot shrink behind the forced-replica half. expect(ROWS.filter((r) => r.refusedOn === 'mode' && r.config.mode === 'local').length).toBeGreaterThanOrEqual(8); - expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(20); + // [#25020] Both modes of the `encryptionKey` refusal, floored apart so the + // replica half cannot shrink behind the local half. + expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey').length).toBeGreaterThanOrEqual(13); + expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey' && (r.config.syncUrl || r.config.mode === 'replica')).length).toBeGreaterThanOrEqual(3); + // [#25020, patch round 1] The forced-remote local-url half, floored on its own. + expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey' && r.config.mode === 'remote').length).toBeGreaterThanOrEqual(5); + expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(38); // [#20200] Exactly zero, not a floor: every key the constructor used to // build and ignore is refused at construction now (see the header). expect(ROWS.filter((r) => r.inert).length).toBe(0); @@ -286,7 +352,7 @@ describe('turso config: the constructor, the spec contract and this mirror agree // `../turso-driver.ts` (the spec keeps them module-local); this is the pin // that holds each copy equal to the schema's issue, byte for byte. describe.each(SYNC_KEY_REFUSALS)('$name', (row) => { - it("the constructor's message is the spec contract's, byte for byte (#20200, #20437, #20586)", () => { + it("the constructor's message is the spec contract's, byte for byte (#20200, #20437, #20586, #25020)", () => { const spec = schemaVerdict(SpecTursoConfigSchema, row.config); expect(spec.refusedOn).toBe(row.refusedOn); expect(spec.message).toBeTypeOf('string'); diff --git a/packages/drivers/driver-turso/src/spec/turso.test.ts b/packages/drivers/driver-turso/src/spec/turso.test.ts index 420cf5f9fef..6ab62e84362 100644 --- a/packages/drivers/driver-turso/src/spec/turso.test.ts +++ b/packages/drivers/driver-turso/src/spec/turso.test.ts @@ -59,11 +59,14 @@ describe('TursoConfigSchema', () => { // local `file:` url. The fixture used to put `syncUrl` beside a remote // `libsql://` url, a configuration the driver refuses at construction and // this schema now refuses too. + // [#25020] `encryptionKey` left this fixture: the driver never encrypted the + // replica file with it (its local engine has no encryption codec), and a + // local or replica config that declares it is refused now — pinned in + // `turso-config-constructor-parity.test.ts`. it('should accept config with all fields', () => { const config = TursoConfigSchema.parse({ url: 'file:./local-replica.db', authToken: 'eyJhbGciOi...', - encryptionKey: 'my-secret-key-256', concurrency: 50, syncUrl: 'libsql://my-db-orgname.turso.io', sync: { @@ -73,7 +76,6 @@ describe('TursoConfigSchema', () => { timeoutMs: 30000, }); - expect(config.encryptionKey).toBe('my-secret-key-256'); expect(config.concurrency).toBe(50); expect(config.timeoutMs).toBe(30000); }); diff --git a/packages/drivers/driver-turso/src/spec/turso.zod.ts b/packages/drivers/driver-turso/src/spec/turso.zod.ts index e25cca4d550..5bc32af4635 100644 --- a/packages/drivers/driver-turso/src/spec/turso.zod.ts +++ b/packages/drivers/driver-turso/src/spec/turso.zod.ts @@ -151,11 +151,12 @@ interface TursoTransportKeys { syncUrl?: unknown; mode?: TursoTransportModeName; timeoutMs?: unknown; + encryptionKey?: unknown; } /** One refusal: the key it sits on and its message. */ interface TursoTransportIssue { - path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode'; + path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode' | 'encryptionKey'; message: string; } @@ -275,6 +276,26 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`).", }]; } + if (typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0) { + // #25020. The url and the mode are coherent (every other shape met a + // refusal above), so what the runtime cannot honour is the KEY: the + // local engine has no encryption codec, so the issue sits on + // `encryptionKey`. An empty key is unset, as the loaders read it. + return [{ + path: 'encryptionKey', + message: + `\`encryptionKey\` asks for this datasource's database file to be encrypted, but this datasource is ${arm}, ` + + 'which runs every read and write through a local SQLite engine that has no encryption codec' + + (mode === 'replica' + ? ' (the sync client shares that one file, and a file it encrypted could not be opened by that engine)' + : '') + + ': nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens ' + + 'without the key — the turso driver refuses this configuration when it starts. Nothing in this driver ' + + 'encrypts a local or replica file. Drop `encryptionKey`, and encrypt the disk or volume the file lives ' + + "on instead, or keep the data in a remote Turso database, whose storage encryption is the server's to " + + "configure, not this key's.", + }]; + } return []; } @@ -303,6 +324,32 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "file beside `syncUrl`: `url: 'file:./data/replica.db'`.", }); } + if ( + (isFileTursoUrl(url) || url === ':memory:') + && typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0 + ) { + // #25020, patch round 1. Only a FORCED remote mode reaches here with a + // `file:` url or `:memory:` (with no `mode`, those urls select a local + // engine). The key is judged by where the database lives, not by the + // mode: `connect()` hands that url to the libSQL client this driver + // builds, which is handed no key, so the issue sits on `encryptionKey`, + // last, like the local-engine arm's. + issues.push({ + path: 'encryptionKey', + message: namesInMemoryTursoDatabase(url) + ? "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " + + "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this " + + 'driver builds, which is handed no key: there is no file for the key to encrypt, so it would change ' + + 'nothing — the turso driver refuses this configuration when it starts. Drop `encryptionKey`.' + : "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local " + + "`file:` url, and `mode: 'remote'` runs every read and write of that file through the libSQL client " + + 'this driver builds, which is handed no key: nothing would encrypt the file, so it would be written ' + + 'in plaintext that any SQLite reader opens without the key — the turso driver refuses this ' + + 'configuration when it starts. Nothing in this driver encrypts a local file. Drop `encryptionKey`, ' + + 'and encrypt the disk or volume the file lives on instead, or point `url` at a remote Turso ' + + "database, whose storage encryption is the server's to configure, not this key's.", + }); + } return issues; } @@ -325,11 +372,21 @@ export const TursoConfigSchema = lazySchema(() => z.object({ authToken: z.string().optional().describe('Authentication token for remote database'), /** - * Encryption Key. - * When provided, encrypts the local database file at rest using AES-256. - * Applies to both local-only and embedded replica modes. + * Encryption Key — encrypts nothing, in any mode, and is REFUSED for a local + * or embedded-replica database, and beside a `file:` url or `:memory:` under + * a forced `mode: 'remote'` (#25020; the refusals are the + * `tursoTransportIssues` arms above, in the spec contract's words — this + * mirror declares no `mode`, so it judges such a config as the local + * database its url selects, and refuses it with that arm's text). + * + * It used to promise to encrypt the local database file at rest with + * AES-256. The driver reads and writes that file through better-sqlite3, + * which has no encryption codec, so the file was written in plaintext that + * any SQLite reader opened without the key; an embedded replica handed it to + * its sync client only. The driver does not forward it to a remote database + * either, whose storage encryption is the server's. */ - encryptionKey: z.string().optional().describe('Encryption key for local database file (AES-256)'), + encryptionKey: z.string().optional().describe("Not honoured in any mode: refused for a local or replica datasource, whose SQLite engine cannot encrypt the file, and for a file: url or :memory: under a forced remote mode, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key"), /** * Concurrency Limit. diff --git a/packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts new file mode 100644 index 00000000000..052fe45b36d --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25020] A non-empty `encryptionKey` on the local engine is refused at + * construction, in a local mode and in an embedded-replica mode alike. The + * ADR-0049 answer to a declared protection the runtime never applied. + * + * # What was measured (the reading this refusal stands on) + * + * On this package's source before the change, with a test key only: + * + * ``` + * local (file: url + encryptionKey) -> constructs, connects, writes and reads back; + * the file starts `SQLite format 3`, and plain + * better-sqlite3 read-only, with no key, reads the row + * replica (+ syncUrl, a supplied client) -> the same: the key reaches nothing + * replica (+ syncUrl, the driver builds the sync client) -> the Knex base writes the file in plaintext + * first; the key reaches only `@libsql/client` + * ``` + * + * The engine every read and write goes through is better-sqlite3 13.0.3 + * (SQLite 3.53.4): no codec compile option, and `PRAGMA key` is a silent + * no-op. Honouring the key would mean replacing that engine, so the key is + * refused instead (the seat ruling on #25020). + * + * # What this file pins + * + * The refusal per mode as the ADR-0112 envelope (`code` + `status`) plus the + * message's first sentence, which names the mode; that the refusal fires + * before any database file is opened; and that a datasource with no key (or + * an empty one, which is unset) connects, writes and reads back exactly as + * before, its file a plain SQLite database. The message equals the spec + * contract's issue byte for byte; that equality is pinned in + * `spec/turso-config-constructor-parity.test.ts`, not here. + * + * [#25020, patch round 1] The same key beside a `file:` url or `:memory:` under + * a forced `mode: 'remote'` is refused too, in its own words: that mode skipped + * every local-engine refusal, and `connect()` handed the local url to the + * libSQL client the driver builds, which is handed no key. Its controls: the + * same shape with no key or an empty one, and a `libsql://` url with a key, + * still construct. + * + * The replica pins need no sync server: the refusal fires at construction, + * before any client is built. A replica with the key and a REACHABLE sync + * server was not measured — no libSQL sync server runs in this package's test + * environment, and the native binding's default sync path speaks the server's + * replication protocol, which nothing here can stand in for. + * + * # Reverse verification: direction predicted before it was run + * + * Remove the refusal from the constructor and every refusal case here goes + * RED (the constructor returns a driver, and the file is created on connect). + * Every control stays GREEN. Measured; see the PR. + */ + +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, describe, expect, it } from 'vitest'; +import { createTursoDriver } from './index.js'; +import { TursoDriver, type TursoDriverConfig } from './turso-driver.js'; + +type Refusal = Error & { code?: string; status?: number }; + +/** The error `build` threw, or `null` when it returned. */ +function refusalOf(build: () => unknown): Refusal | null { + try { + build(); + return null; + } catch (error) { + return error as Refusal; + } +} + +const DIR = mkdtempSync(join(tmpdir(), 'turso-25020-')); +afterAll(() => rmSync(DIR, { recursive: true, force: true })); + +let seq = 0; +/** A fresh database path in its own name; the file does not exist yet. */ +const nextPath = () => join(DIR, `db-${++seq}.db`); + +const PRIMARY = 'libsql://primary.example.invalid'; +/** A test value only — never a real key. */ +const KEY = 'test-key-25020'; + +const LOCAL_FIRST_SENTENCE = + "`encryptionKey` asks for this datasource's database file to be encrypted, but this datasource is a local " + + 'database, which runs every read and write through a local SQLite engine that has no encryption codec: ' + + 'nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens without ' + + 'the key — the turso driver refuses this configuration when it starts.'; +const REPLICA_FIRST_SENTENCE = + "`encryptionKey` asks for this datasource's database file to be encrypted, but this datasource is an " + + 'embedded replica, which runs every read and write through a local SQLite engine that has no encryption ' + + 'codec (the sync client shares that one file, and a file it encrypted could not be opened by that ' + + 'engine): nothing would encrypt the file, so it would be written in plaintext that any SQLite reader ' + + 'opens without the key — the turso driver refuses this configuration when it starts.'; + +const REMOTE_FILE_FIRST_SENTENCE = + "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local `file:` " + + "url, and `mode: 'remote'` runs every read and write of that file through the libSQL client this driver " + + 'builds, which is handed no key: nothing would encrypt the file, so it would be written in plaintext that ' + + 'any SQLite reader opens without the key — the turso driver refuses this configuration when it starts.'; +const REMOTE_MEMORY_FIRST_SENTENCE = + "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " + + "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this driver " + + 'builds, which is handed no key: there is no file for the key to encrypt, so it would change nothing — ' + + 'the turso driver refuses this configuration when it starts.'; + +/** A client that never touches a network: the replica arm takes it instead of building one. */ +const offlineClient = () => ({ close() {}, sync: async () => ({}) }) as unknown as TursoDriverConfig['client']; + +describe('a local database: `encryptionKey` is refused at construction', () => { + it.each<[string, (path: string) => TursoDriverConfig]>([ + ['a file: url', (path) => ({ url: `file:${path}`, encryptionKey: KEY })], + ['an uppercase FILE: url', (path) => ({ url: `FILE:${path}`, encryptionKey: KEY })], + ["a forced mode: 'local'", (path) => ({ url: `file:${path}`, mode: 'local', encryptionKey: KEY })], + ])('%s: VALIDATION_ERROR / 400, naming a local database, and no file is opened', (_name, configOf) => { + const path = nextPath(); + const refusal = refusalOf(() => new TursoDriver(configOf(path))); + + expect(refusal).not.toBeNull(); + expect(refusal!.code).toBe('VALIDATION_ERROR'); + expect(refusal!.status).toBe(400); + expect(refusal!.message.startsWith(LOCAL_FIRST_SENTENCE), refusal!.message).toBe(true); + expect(existsSync(path)).toBe(false); + }); + + it(':memory: is refused the same way', () => { + const refusal = refusalOf(() => new TursoDriver({ url: ':memory:', encryptionKey: KEY })); + + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); + expect(refusal!.message.startsWith(LOCAL_FIRST_SENTENCE)).toBe(true); + }); + + it('createTursoDriver() is the same constructor, and refuses the same config', () => { + const refusal = refusalOf(() => createTursoDriver({ url: `file:${nextPath()}`, encryptionKey: KEY })); + + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); + }); +}); + +describe('an embedded replica: `encryptionKey` is refused at construction', () => { + it.each<[string, (path: string) => TursoDriverConfig]>([ + ['file: + syncUrl, the driver building the sync client', (path) => ({ + url: `file:${path}`, syncUrl: PRIMARY, sync: { onConnect: false }, encryptionKey: KEY, + })], + ['file: + syncUrl, a supplied client', (path) => ({ + url: `file:${path}`, syncUrl: PRIMARY, client: offlineClient(), sync: { onConnect: false }, encryptionKey: KEY, + })], + ["file: + syncUrl under a forced mode: 'replica'", (path) => ({ + url: `file:${path}`, mode: 'replica', syncUrl: PRIMARY, sync: { onConnect: false }, encryptionKey: KEY, + })], + ])('%s: VALIDATION_ERROR / 400, naming an embedded replica, and no file is opened', (_name, configOf) => { + const path = nextPath(); + const refusal = refusalOf(() => new TursoDriver(configOf(path))); + + expect(refusal).not.toBeNull(); + expect(refusal!.code).toBe('VALIDATION_ERROR'); + expect(refusal!.status).toBe(400); + expect(refusal!.message.startsWith(REPLICA_FIRST_SENTENCE), refusal!.message).toBe(true); + expect(existsSync(path)).toBe(false); + }); + + it('the message names no url and no key', () => { + const refusal = refusalOf( + () => new TursoDriver({ url: `file:${join(DIR, 'private-dir.db')}`, syncUrl: 'libsql://private-host.example.invalid', encryptionKey: 'private-key-value' }), + ); + + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal!.message).not.toContain('private-dir'); + expect(refusal!.message).not.toContain('private-host'); + expect(refusal!.message).not.toContain('private-key-value'); + }); +}); + +// [#25020, patch round 1] A forced `mode: 'remote'` answers first in `detectMode`, so this shape +// skipped every local-engine refusal, and `connect()` handed the local url to the libSQL client the +// driver builds, with no key. Judged by where the database lives, not by the mode. +describe("a local url under a forced mode: 'remote': `encryptionKey` is refused at construction", () => { + it.each<[string, (path: string) => TursoDriverConfig]>([ + ['a file: url', (path) => ({ url: `file:${path}`, mode: 'remote', encryptionKey: KEY })], + ['an uppercase FILE: url', (path) => ({ url: `FILE:${path}`, mode: 'remote', encryptionKey: KEY })], + ['a file: url beside timeout', (path) => ({ url: `file:${path}`, mode: 'remote', timeout: 5000, encryptionKey: KEY })], + ])('%s: VALIDATION_ERROR / 400, naming the forced remote mode, and no file is opened', (_name, configOf) => { + const path = nextPath(); + const refusal = refusalOf(() => new TursoDriver(configOf(path))); + + expect(refusal).not.toBeNull(); + expect(refusal!.code).toBe('VALIDATION_ERROR'); + expect(refusal!.status).toBe(400); + expect(refusal!.message.startsWith(REMOTE_FILE_FIRST_SENTENCE), refusal!.message).toBe(true); + expect(existsSync(path)).toBe(false); + }); + + it.each([':memory:', 'file::memory:'])('%s: refused in the in-memory words', (url) => { + const refusal = refusalOf(() => new TursoDriver({ url, mode: 'remote', encryptionKey: KEY })); + + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); + expect(refusal!.message.startsWith(REMOTE_MEMORY_FIRST_SENTENCE), refusal!.message).toBe(true); + }); + + it('createTursoDriver() refuses the same config, and the message names no url and no key', () => { + const refusal = refusalOf( + () => createTursoDriver({ url: `file:${join(DIR, 'private-dir.db')}`, mode: 'remote', encryptionKey: 'private-key-value' }), + ); + + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); + expect(refusal!.message).not.toContain('private-dir'); + expect(refusal!.message).not.toContain('private-key-value'); + }); + + it.each<[string, TursoDriverConfig]>([ + ['the same file: url with no key', { url: `file:${join(DIR, 'remote-no-key.db')}`, mode: 'remote' }], + ['the same file: url with an empty key (unset)', { url: `file:${join(DIR, 'remote-empty-key.db')}`, mode: 'remote', encryptionKey: '' }], + [':memory: with no key', { url: ':memory:', mode: 'remote' }], + ['a libsql:// url with a key (a remote server\'s storage: not judged by this card)', { url: 'libsql://db.example.invalid', encryptionKey: KEY }], + ["a libsql:// url with a key under a forced mode: 'remote'", { url: 'libsql://db.example.invalid', mode: 'remote', encryptionKey: KEY }], + ])('CONTROL: %s still constructs, in remote mode', (_name, config) => { + expect(new TursoDriver(config).transportMode).toBe('remote'); + }); +}); + +describe('CONTROLS — a datasource without a key works exactly as before', () => { + const Database = createRequire(import.meta.url)('better-sqlite3') as new ( + filename: string, + options?: { readonly?: boolean }, + ) => { prepare(sql: string): { all(): Array> }; close(): void }; + const OBJECT = { name: 'probe', fields: { v: { type: 'text' } } }; + + /** Write one row through the driver and read it back through the driver. */ + async function roundTrip(driver: TursoDriver): Promise { + await driver.connect(); + await driver.syncSchema('probe', OBJECT); + await driver.create('probe', { v: 'row-25020' }); + const rows = (await driver.find('probe', {})) as Array<{ v: unknown }>; + await driver.disconnect(); + return rows.map((row) => row.v); + } + + /** The file as any SQLite reader sees it: its header and the row, with no key. */ + function readPlain(path: string) { + const db = new Database(path, { readonly: true }); + try { + return { + header: readFileSync(path).subarray(0, 15).toString('latin1'), + rows: db.prepare('select v from probe').all().map((row) => row.v), + }; + } finally { + db.close(); + } + } + + it.each<[string, (path: string) => TursoDriverConfig]>([ + ['a local file: url with no key', (path) => ({ url: `file:${path}` })], + ['a local file: url with an empty key (unset)', (path) => ({ url: `file:${path}`, encryptionKey: '' })], + ['a replica with no key (a supplied client)', (path) => ({ + url: `file:${path}`, syncUrl: PRIMARY, client: offlineClient(), sync: { onConnect: false }, + })], + ])('%s: constructs, connects, writes and reads back; the file is a plain SQLite database', async (_name, configOf) => { + const path = nextPath(); + const driver = new TursoDriver(configOf(path)); + + expect(await roundTrip(driver)).toEqual(['row-25020']); + expect(readPlain(path)).toEqual({ header: 'SQLite format 3', rows: ['row-25020'] }); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index bc6756449da..cb53a3effd5 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -117,8 +117,15 @@ export interface TursoDriverConfig { authToken?: string; /** - * AES-256 encryption key for the local database file. - * Only effective in local/replica modes. + * Encrypts nothing, in any mode. A non-empty key in a local or replica mode + * is REFUSED at construction (`VALIDATION_ERROR` / 400): this driver reads + * and writes the local file through better-sqlite3, which has no encryption + * codec, so the file would be written in plaintext that any SQLite reader + * opens without the key. Drop it, and encrypt the disk or volume the file + * lives on instead. A `file:` url or `:memory:` under a forced + * `mode: 'remote'` is refused the same way: the libSQL client this driver + * builds for it is handed no key. A remote database's storage encryption is + * the server's; this driver does not forward the key to it. */ encryptionKey?: string; @@ -1451,7 +1458,118 @@ const LOCAL_MODE_WITH_SYNC_URL_REFUSAL = "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`)."; /** - * Throw one of the four sync-key refusals above as the ADR-0112 envelope. + * [#25020] A non-empty `encryptionKey` on the local engine — refused at + * construction, in a local mode and in a replica mode alike. + * + * The key promised "AES-256 encryption key for the local database file", and + * nothing that touches that file could honour it. Every read and write of a + * local or replica datasource runs through Knex on better-sqlite3, and + * better-sqlite3 has no encryption codec: measured on better-sqlite3 13.0.3 + * (SQLite 3.53.4), no codec compile option is set and `PRAGMA key` is a + * silent no-op. So, measured on this package's source before this refusal: + * + * ``` + * local (file: url + encryptionKey) -> constructs, connects, writes; the file + * starts `SQLite format 3`, and plain + * better-sqlite3 reads the row back with no key + * replica (+ syncUrl, a supplied client) -> the same: the key reaches nothing + * replica (+ syncUrl, the driver builds the sync client) -> `super.connect()` writes the file in plaintext + * first; only then is `@libsql/client` handed the + * key (with no reachable sync server here, its + * construction fails to connect) + * @libsql/client + the key, over that plaintext file -> SQLITE_NOTADB + * this driver's replica face over a file @libsql/client + * encrypted with the key -> DATABASE_ERROR (SQLITE_NOTADB) + * ``` + * + * The native libSQL binding DOES encrypt a local file under the key, but it is + * not the engine this driver reads and writes through: honouring the key + * would mean replacing the Knex / better-sqlite3 path with another engine, + * and the ruling on #25020 refuses the key instead of swapping engines. A + * replica is refused for the same reason as a local database, because its + * sync client and its local engine share one file: whichever opens it with the + * key, the other cannot read it, and the engine that serves every read and + * write never encrypts it. + * + * Raised BEFORE `super()`, so no file is opened, and LAST among the + * local-engine refusals: a url the engine cannot open, a sync key nothing + * would read and a forced mode the driver would ignore each keep their own + * message. An empty key is unset, as the datasource loaders read it + * (`buildTursoDriverConfig` drops it). A forced `mode: 'remote'` on a + * `file:` url or `:memory:` meets {@link remoteModeLocalEncryptionKeyRefusal} + * instead; a remote url is not judged here. + * + * ⚠️ The text is `@objectstack/spec`'s `TursoConfigSchema` refusal on + * `encryptionKey`, byte for byte, held equal by + * `spec/turso-config-constructor-parity.test.ts`. Edit it there first. + */ +function localEncryptionKeyRefusal(mode: 'local' | 'replica'): string { + const arm = mode === 'replica' ? 'an embedded replica' : 'a local database'; + return ( + `\`encryptionKey\` asks for this datasource's database file to be encrypted, but this datasource is ${arm}, ` + + 'which runs every read and write through a local SQLite engine that has no encryption codec' + + (mode === 'replica' + ? ' (the sync client shares that one file, and a file it encrypted could not be opened by that engine)' + : '') + + ': nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens ' + + 'without the key — the turso driver refuses this configuration when it starts. Nothing in this driver ' + + 'encrypts a local or replica file. Drop `encryptionKey`, and encrypt the disk or volume the file lives ' + + "on instead, or keep the data in a remote Turso database, whose storage encryption is the server's to " + + "configure, not this key's." + ); +} + +/** + * [#25020, patch round 1] The same key beside a `file:` url or `:memory:` under + * a FORCED `mode: 'remote'` — refused at construction too. + * + * {@link TursoDriver.detectMode} answers the forced mode first, so this shape + * skips every local-engine refusal: `toKnexConfig`'s remote arm opens nothing, + * and `connect()` hands the url to {@link TursoDriver.createRemoteClient}, + * which builds `createClient({ url, authToken, concurrency })` with no key — + * and `@libsql/client` opens the local file for a `file:` url (the reading in + * {@link startsWithScheme}'s docblock; the parity table's row "file: under a + * forced mode: 'remote'" and `turso-driver-unrecognised-url-refusal.test.ts` + * pin the shape as a supported one). Every read and write then runs through + * the remote transport over that file, in plaintext. Honouring the key here + * is not the route: it would make a file's encryption depend on a transport + * override, a capability nothing pulls, and the key is proposed for + * retirement. So it is refused, in its own words — one text for a file, one + * for an in-memory database, which has no file to encrypt. + * + * Raised after every forced-remote refusal above, so a `syncUrl` or a lone + * `sync` beside it keeps its own message. A remote url (`libsql://`, + * `https://`, `ws(s)://`) is not judged here: there the storage is the + * server's. + * + * ⚠️ The text is `@objectstack/spec`'s `TursoConfigSchema` refusal on + * `encryptionKey` in its remote arm, byte for byte, held equal by + * `spec/turso-config-constructor-parity.test.ts`. Edit it there first. + */ +function remoteModeLocalEncryptionKeyRefusal(url: string): string { + if (namesInMemoryDatabase(url)) { + return ( + "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " + + "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this " + + 'driver builds, which is handed no key: there is no file for the key to encrypt, so it would change ' + + 'nothing — the turso driver refuses this configuration when it starts. Drop `encryptionKey`.' + ); + } + return ( + "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local " + + "`file:` url, and `mode: 'remote'` runs every read and write of that file through the libSQL client " + + 'this driver builds, which is handed no key: nothing would encrypt the file, so it would be written ' + + 'in plaintext that any SQLite reader opens without the key — the turso driver refuses this ' + + 'configuration when it starts. Nothing in this driver encrypts a local file. Drop `encryptionKey`, ' + + 'and encrypt the disk or volume the file lives on instead, or point `url` at a remote Turso ' + + "database, whose storage encryption is the server's to configure, not this key's." + ); +} + +/** + * Throw one of the four sync-key refusals above, or the local-engine + * `encryptionKey` refusal ({@link localEncryptionKeyRefusal}), as the + * ADR-0112 envelope. * * Raised BEFORE `super()`, AFTER the three refusals above it in the * constructor, so no configuration those already refuse changes which message @@ -1517,6 +1635,15 @@ function namesInMemoryDatabase(url: string): boolean { return path === ':memory:' || path.startsWith(':memory:?'); } +/** + * [#25020] A url naming a database that lives on THIS host — a `file:` url + * (any letter case) or `:memory:` — whichever engine opens it. The + * `encryptionKey` refusal is judged by this, not by the transport mode. + */ +function namesLocalDatabase(url: string): boolean { + return url === ':memory:' || isFileUrl(url); +} + type LocalEngineDefect = 'remote-url' | 'unrecognised-url' | 'in-memory-replica'; /** @@ -1957,6 +2084,20 @@ export class TursoDriver extends SqlDriver { if (mode === 'local' && config.syncUrl) { refuseIgnoredSyncKey(LOCAL_MODE_WITH_SYNC_URL_REFUSAL); } + // [#25020] A key nothing this driver builds can honour, judged by WHERE + // THE DATABASE LIVES, not by the mode: a `file:` url or `:memory:`. + // In a local or replica mode better-sqlite3 has no encryption codec; under + // a forced `mode: 'remote'` the libSQL client `createRemoteClient()` builds + // for that url is handed no key. Either way the file would be written in + // plaintext. Refused before `super()`, in the spec's words, last. (In a + // local or replica mode every url that reaches here is a `file:` url or + // `:memory:`: `localEngineDefect` refused the rest above.) See + // `localEncryptionKeyRefusal` and `remoteModeLocalEncryptionKeyRefusal`. + if (config.encryptionKey && namesLocalDatabase(config.url)) { + refuseIgnoredSyncKey( + mode === 'remote' ? remoteModeLocalEncryptionKeyRefusal(config.url) : localEncryptionKeyRefusal(mode), + ); + } const knexConfig = TursoDriver.toKnexConfig(config, mode); super(knexConfig); this.tursoConfig = config; @@ -2265,10 +2406,13 @@ export class TursoDriver extends SqlDriver { // binding's BUSY timeout for local lock contention, not the remote // operation timeout `TursoDriverConfig.timeout` promises. That promise // is kept on `sync()`, the one remote operation this arm performs. + // [#25020] And no `encryptionKey`: a non-empty one is refused at + // construction, because this client shares the replica file with the + // Knex / better-sqlite3 engine, which cannot encrypt it, and a file + // this client encrypted is one that engine cannot open. this.libsqlClient = createClient({ url: this.tursoConfig.url, authToken: this.tursoConfig.authToken, - encryptionKey: this.tursoConfig.encryptionKey, syncUrl: this.tursoConfig.syncUrl, concurrency: this.tursoConfig.concurrency, }); diff --git a/packages/services/service-datasource/src/turso-driver-config.ts b/packages/services/service-datasource/src/turso-driver-config.ts index 565454defd5..a23c80cddf6 100644 --- a/packages/services/service-datasource/src/turso-driver-config.ts +++ b/packages/services/service-datasource/src/turso-driver-config.ts @@ -63,7 +63,13 @@ export interface TursoDriverConfigInput { url: string; /** JWT auth token for a remote Turso database. */ authToken?: string; - /** AES-256 key for the local database file (local/replica modes). */ + /** + * Passed through as authored, and honoured by the driver in no mode: it + * refuses a non-empty key beside a `file:` url or `:memory:` in every mode + * (`VALIDATION_ERROR` / 400), because nothing it builds encrypts a local + * file, and does not forward it to a remote database, whose storage + * encryption is the server's. + */ encryptionKey?: string; /** Max concurrent requests to the remote database (replica/remote modes). */ concurrency?: number; diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index b543590328e..e18b80110ad 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -55,12 +55,14 @@ * validation is a structural name/label check. So the only place these can * still be found is exactly the place this module reads: a stored row. * 3. **Credential-shaped keys that are still WRITABLE** ({@link - * STILL_WRITABLE_CREDENTIAL_KEYS}) — today just turso's `encryptionKey`, an - * AES-256 key the binder has no slot for (#8081 scope item 4, which owns - * the decision about giving it one). Redacting it on READ neither grants - * nor removes that slot: the key stays writable, stays stored, and stays - * injected at connect. It simply stops being served back in cleartext, - * which is the one question this module answers. + * STILL_WRITABLE_CREDENTIAL_KEYS}) — today just turso's `encryptionKey`, a + * key the binder has no slot for (#8081 scope item 4, which owns the + * decision about giving it one). Since #25020 it is refused for a local + * database file and honoured by the driver in no mode, but a remote + * datasource still parses with it and a stored row may still carry it. + * Redacting it on READ neither grants nor removes a slot: where the key is + * still writable it stays stored. It simply stops being served back in + * cleartext, which is the one question this module answers. * * For a driver the platform ships no contract for, source 1 is empty — the * registry is saying "nothing to check against", not "nothing to protect". The @@ -123,10 +125,14 @@ import { getDriverConfigSchema, resolveDriverId } from './driver/config-registry * Credential-shaped config keys that remain WRITABLE by deliberate spec choice, * and so are never found by the `z.never()` derivation. * - * `encryptionKey` (turso) is an AES-256 key for the local database file. #8078 - * left it writable because the datasource secret binder injects exactly one - * secret slot and `external.credentialsRef` resolution cannot target a second - * one; giving it a slot is #8081 scope item 4 and is NOT decided here. + * `encryptionKey` (turso) was declared an AES-256 key for the local database + * file. #8078 left it writable because the datasource secret binder injects + * exactly one secret slot and `external.credentialsRef` resolution cannot + * target a second one; giving it a slot is #8081 scope item 4 and is NOT + * decided here. Since #25020 the turso contract refuses it for a local or + * replica datasource (the driver's SQLite engine cannot encrypt the file), but + * a remote datasource still parses with it and a stored row may still carry + * it, so it stays on this list. * * Keyed by CANONICAL driver id and looked up through {@link resolveDriverId}, * exactly like {@link PASSTHROUGH_SECRET_PATHS} below: every spelling the diff --git a/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts b/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts index cc576909fac..c4f6172e43b 100644 --- a/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts @@ -84,8 +84,13 @@ const FAMILY = [ valid: {}, literal: 'libsql://x.turso.io', placeholder: 'libsql://${TURSO_HOST}' }, { name: 'turso syncUrl', schema: TursoConfigSchema, key: 'syncUrl', valid: { url: 'file:./data/replica.db' }, literal: 'libsql://x.turso.io', placeholder: 'libsql://${TURSO_SYNC_HOST}' }, + // A remote url since #25020: a local or replica mode now refuses ANY + // non-empty `encryptionKey` (its SQLite engine cannot encrypt the file — + // pinned in `turso.test.ts`), so the literal half of this row can only be + // judged where the key still parses. This row pins the value-level + // placeholder judgement, not that a remote mode honours the key: it does not. { name: 'turso encryptionKey', schema: TursoConfigSchema, key: 'encryptionKey', - valid: { url: 'file:./data/local.db' }, literal: 'a'.repeat(32), placeholder: '${TURSO_ENCRYPTION_KEY}' }, + valid: { url: 'libsql://x.turso.io' }, literal: 'a'.repeat(32), placeholder: '${TURSO_ENCRYPTION_KEY}' }, { name: 'sqlite filename', schema: SqliteConfigSchema, key: 'filename', valid: {}, literal: './data/objectstack.db', placeholder: '${DATA_DIR}/objectstack.db' }, { name: 'sqlite-wasm filename', schema: SqliteWasmConfigSchema, key: 'filename', diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index 9b57799ac4a..86c80898ace 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -471,6 +471,202 @@ describe('TursoConfigSchema refuses what the turso driver refuses', () => { }); }); + // #25020 — a declared encryption nothing applies. The driver reads and writes + // a local or replica file through better-sqlite3, which has no encryption + // codec, so a datasource that declared `encryptionKey` was written in + // plaintext that any SQLite reader opened without the key. Both doors now + // refuse it, on `encryptionKey`, in one message per mode. A remote mode is + // not judged by this arm. + describe('a non-empty `encryptionKey` in a local or replica mode — refused on `encryptionKey`', () => { + const LOCAL_FIRST_SENTENCE = + "`encryptionKey` asks for this datasource's database file to be encrypted, but this datasource is a local " + + 'database, which runs every read and write through a local SQLite engine that has no encryption codec: ' + + 'nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens without ' + + 'the key — the turso driver refuses this configuration when it starts.'; + const REPLICA_FIRST_SENTENCE = + "`encryptionKey` asks for this datasource's database file to be encrypted, but this datasource is an " + + 'embedded replica, which runs every read and write through a local SQLite engine that has no encryption ' + + 'codec (the sync client shares that one file, and a file it encrypted could not be opened by that ' + + 'engine): nothing would encrypt the file, so it would be written in plaintext that any SQLite reader ' + + 'opens without the key — the turso driver refuses this configuration when it starts.'; + const WAY_OUT = 'Drop `encryptionKey`, and encrypt the disk or volume the file lives on instead'; + const KEY = 'test-key-25020'; + + it('a local database — a `file:` url in any case, `:memory:`, a forced local mode', () => { + for (const config of [ + { url: 'file:./data/app.db', encryptionKey: KEY }, + { url: 'FILE:./data/app.db', encryptionKey: KEY }, + { url: ' file:./data/app.db', encryptionKey: KEY }, + { url: ':memory:', encryptionKey: KEY }, + { url: 'file:./data/app.db', mode: 'local', encryptionKey: KEY }, + { url: 'file:./data/app.db', mode: 'local', syncUrl: '', encryptionKey: KEY }, + ]) { + const { path, message } = refusal(config); + expect(path, JSON.stringify(config)).toBe('encryptionKey'); + expect(message.startsWith(LOCAL_FIRST_SENTENCE), message).toBe(true); + expect(message).toContain(WAY_OUT); + } + }); + + it('an embedded replica — selected by `syncUrl` or forced, whatever it syncs from', () => { + for (const config of [ + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', sync: { onConnect: false }, encryptionKey: KEY }, + { url: 'file:./data/replica.db', syncUrl: 'wss://db.turso.io', timeoutMs: 5000, encryptionKey: KEY }, + { url: 'file:./data/replica.db', mode: 'replica', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, + ]) { + const { path, message } = refusal(config); + expect(path, JSON.stringify(config)).toBe('encryptionKey'); + expect(message.startsWith(REPLICA_FIRST_SENTENCE), message).toBe(true); + expect(message).toContain(WAY_OUT); + } + }); + + it('never echoes the key or either url', () => { + const { message } = refusal({ + url: 'file:./private-dir/replica.db', + syncUrl: 'libsql://private-host.turso.io', + encryptionKey: 'private-key-value', + }); + expect(message).not.toContain('private-dir'); + expect(message).not.toContain('private-host'); + expect(message).not.toContain('private-key-value'); + }); + + it('comes last: a url, a forced mode or a lone `sync` another refusal takes keeps that refusal', () => { + for (const [config, onKey] of [ + [{ url: './data/app.db', encryptionKey: KEY }, 'url'], + [{ url: 'libsql://db.turso.io', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'url'], + [{ url: ':memory:', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'url'], + [{ url: 'file:./data/replica.db', mode: 'replica', encryptionKey: KEY }, 'mode'], + [{ url: 'file:./data/app.db', mode: 'local', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'mode'], + ] as const) { + const { path } = refusal(config); + expect(path, JSON.stringify(config)).toBe(onKey); + } + // A lone `sync` is the refinement's own first issue; this arm stands beside it, second. + const result = TursoConfigSchema.safeParse({ url: 'file:./data/app.db', sync: { intervalSeconds: 60 }, encryptionKey: KEY }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['sync', 'encryptionKey']); + }); + + it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => { + const config = { url: 'file:./data/app.db', encryptionKey: KEY }; + const datasource = DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config }); + expect(datasource.success).toBe(false); + const issue = datasource.error!.issues.find((i) => i.path.join('.') === 'config.encryptionKey'); + expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined(); + expect(issue!.message.startsWith(LOCAL_FIRST_SENTENCE)).toBe(true); + + const verdict = validateDriverConfig('turso', config); + expect(verdict.known).toBe(true); + expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['encryptionKey']); + }); + + it('controls: an empty `encryptionKey` is unset, and a local or replica config without one stays accepted', () => { + for (const config of [ + { url: 'file:./data/app.db', encryptionKey: '' }, + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', encryptionKey: '' }, + { url: 'file:./data/app.db' }, + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io' }, + ]) { + const result = TursoConfigSchema.safeParse(config); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it('the describe no longer promises an encrypted file', () => { + const description = TursoConfigSchema.shape.encryptionKey.description ?? ''; + expect(description).not.toMatch(/AES-256/); + expect(description).toContain('refused for a local or replica datasource'); + expect(description).toContain('under a forced remote mode'); + }); + }); + + // #25020, patch round 1 — the same key beside a local url under a FORCED + // `mode: 'remote'`. The forced mode skipped every local-engine refusal, and + // `connect()` handed the url to the libSQL client the driver builds, which is + // handed no key: plaintext all the same. Judged by where the database lives, + // not by the mode, in its own words. + describe("a non-empty `encryptionKey` beside a `file:` url or `:memory:` under a forced `mode: 'remote'` — refused on `encryptionKey`", () => { + const FILE_FIRST_SENTENCE = + "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local `file:` " + + "url, and `mode: 'remote'` runs every read and write of that file through the libSQL client this driver " + + 'builds, which is handed no key: nothing would encrypt the file, so it would be written in plaintext that ' + + 'any SQLite reader opens without the key — the turso driver refuses this configuration when it starts.'; + const MEMORY_FIRST_SENTENCE = + "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " + + "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this driver " + + 'builds, which is handed no key: there is no file for the key to encrypt, so it would change nothing — ' + + 'the turso driver refuses this configuration when it starts.'; + const KEY = 'test-key-25020'; + + it('a `file:` url in any case, behind whitespace, beside `timeoutMs`', () => { + for (const config of [ + { url: 'file:./data/app.db', mode: 'remote', encryptionKey: KEY }, + { url: 'FILE:./data/app.db', mode: 'remote', encryptionKey: KEY }, + { url: ' file:./data/app.db', mode: 'remote', encryptionKey: KEY }, + { url: 'file:./data/app.db', mode: 'remote', timeoutMs: 5000, encryptionKey: KEY }, + ]) { + const { path, message } = refusal(config); + expect(path, JSON.stringify(config)).toBe('encryptionKey'); + expect(message.startsWith(FILE_FIRST_SENTENCE), message).toBe(true); + expect(message).toContain('Drop `encryptionKey`, and encrypt the disk or volume the file lives on instead'); + } + }); + + it('an in-memory url — `:memory:`, `file::memory:`, a `:memory:?` query — in its own words', () => { + for (const url of [':memory:', 'file::memory:', 'FILE::memory:', 'file::memory:?cache=shared']) { + const { path, message } = refusal({ url, mode: 'remote', encryptionKey: KEY }); + expect(path, url).toBe('encryptionKey'); + expect(message.startsWith(MEMORY_FIRST_SENTENCE), message).toBe(true); + } + }); + + it('never echoes the key or the url', () => { + const { message } = refusal({ url: 'file:./private-dir/app.db', mode: 'remote', encryptionKey: 'private-key-value' }); + expect(message).not.toContain('private-dir'); + expect(message).not.toContain('private-key-value'); + }); + + it('comes last in the remote arm: a `syncUrl` or a lone `sync` keeps its own refusal, first', () => { + for (const [config, first] of [ + [{ url: 'file:./data/app.db', mode: 'remote', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'syncUrl'], + [{ url: 'file:./data/app.db', mode: 'remote', sync: { onConnect: true }, encryptionKey: KEY }, 'sync'], + ] as const) { + const result = TursoConfigSchema.safeParse(config); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.')), JSON.stringify(config)).toEqual([first, 'encryptionKey']); + } + }); + + it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => { + const config = { url: 'file:./data/app.db', mode: 'remote', encryptionKey: KEY }; + const datasource = DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config }); + expect(datasource.success).toBe(false); + const issue = datasource.error!.issues.find((i) => i.path.join('.') === 'config.encryptionKey'); + expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined(); + expect(issue!.message.startsWith(FILE_FIRST_SENTENCE)).toBe(true); + + const verdict = validateDriverConfig('turso', config); + expect(verdict.known).toBe(true); + expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['encryptionKey']); + }); + + it('controls: no key or an empty one, and a remote url with a key (not judged by this card), stay accepted', () => { + for (const config of [ + { url: 'file:./data/app.db', mode: 'remote' }, + { url: 'file:./data/app.db', mode: 'remote', encryptionKey: '' }, + { url: ':memory:', mode: 'remote' }, + { url: 'libsql://db.turso.io', encryptionKey: KEY }, + { url: 'libsql://db.turso.io', mode: 'remote', encryptionKey: KEY }, + ]) { + const result = TursoConfigSchema.safeParse(config); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + }); + it('the pre-existing `sync`-without-`syncUrl` refusal is unchanged, and stands beside a transport refusal', () => { const result = TursoConfigSchema.safeParse({ url: './data/app.db', sync: { intervalSeconds: 60 } }); expect(result.success).toBe(false); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 8ecc2e683f1..983deddfdd2 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -108,7 +108,23 @@ export type TursoTransportMode = z.input; // other way round: the driver used to label it local and then run it as an // embedded replica, syncing with the remote on connect and on the interval // while its sync-enabled check answered true. Authoring and the constructor -// now refuse it together, in this arm's words. +// now refuse it together, in this arm's words; +// - a non-empty `encryptionKey` in a local or replica mode (#25020): the +// driver runs every read and write of the file through better-sqlite3, +// which has no encryption codec (`PRAGMA key` is a silent no-op there), so +// the key encrypted nothing and the file was written in plaintext that any +// SQLite reader opened without it. An embedded replica is no different: +// only its sync client is handed the key, and a file that client encrypted +// is one better-sqlite3 cannot open. Authoring and the constructor now +// refuse it together, in this arm's words, last among the local-engine +// refusals; +// - the same key beside a `file:` url or `:memory:` under a forced +// `mode: 'remote'` (#25020, patch round 1): the key is judged by where the +// database lives, not by the mode. `connect()` hands that url to the +// libSQL client this driver builds, which is handed no key, so the file +// was written in plaintext all the same. Refused last in the remote arm, +// in its own words (one text for a file, one for an in-memory database). +// A remote url (`libsql://`, `https://`, `ws(s)://`) is not judged here. // // The predicates MIRROR the constructor's on `main` (`localEngineDefect`, // `refuseWebSocketTimeout` and `detectMode` in @@ -164,11 +180,12 @@ interface TursoTransportKeys { syncUrl?: unknown; mode?: TursoTransportMode; timeoutMs?: unknown; + encryptionKey?: unknown; } /** One refusal: the key it sits on and its message. */ interface TursoTransportIssue { - path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode'; + path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode' | 'encryptionKey'; message: string; } @@ -284,6 +301,26 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`).", }]; } + if (typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0) { + // #25020. The url and the mode are coherent (every other shape met a + // refusal above), so what the runtime cannot honour is the KEY: the + // local engine has no encryption codec, so the issue sits on + // `encryptionKey`. An empty key is unset, as the loaders read it. + return [{ + path: 'encryptionKey', + message: + `\`encryptionKey\` asks for this datasource's database file to be encrypted, but this datasource is ${arm}, ` + + 'which runs every read and write through a local SQLite engine that has no encryption codec' + + (mode === 'replica' + ? ' (the sync client shares that one file, and a file it encrypted could not be opened by that engine)' + : '') + + ': nothing would encrypt the file, so it would be written in plaintext that any SQLite reader opens ' + + 'without the key — the turso driver refuses this configuration when it starts. Nothing in this driver ' + + 'encrypts a local or replica file. Drop `encryptionKey`, and encrypt the disk or volume the file lives ' + + "on instead, or keep the data in a remote Turso database, whose storage encryption is the server's to " + + "configure, not this key's.", + }]; + } return []; } @@ -312,6 +349,32 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "file beside `syncUrl`: `url: 'file:./data/replica.db'`.", }); } + if ( + (isFileTursoUrl(url) || url === ':memory:') + && typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0 + ) { + // #25020, patch round 1. Only a FORCED remote mode reaches here with a + // `file:` url or `:memory:` (with no `mode`, those urls select a local + // engine). The key is judged by where the database lives, not by the + // mode: `connect()` hands that url to the libSQL client this driver + // builds, which is handed no key, so the issue sits on `encryptionKey`, + // last, like the local-engine arm's. + issues.push({ + path: 'encryptionKey', + message: namesInMemoryTursoDatabase(url) + ? "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " + + "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this " + + 'driver builds, which is handed no key: there is no file for the key to encrypt, so it would change ' + + 'nothing — the turso driver refuses this configuration when it starts. Drop `encryptionKey`.' + : "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local " + + "`file:` url, and `mode: 'remote'` runs every read and write of that file through the libSQL client " + + 'this driver builds, which is handed no key: nothing would encrypt the file, so it would be written ' + + 'in plaintext that any SQLite reader opens without the key — the turso driver refuses this ' + + 'configuration when it starts. Nothing in this driver encrypts a local file. Drop `encryptionKey`, ' + + 'and encrypt the disk or volume the file lives on instead, or point `url` at a remote Turso ' + + "database, whose storage encryption is the server's to configure, not this key's.", + }); + } return issues; } @@ -425,13 +488,25 @@ export const TursoConfigSchema = lazySchema(() => strictObject( authToken: refusedInlineCredentialKey('authToken', 'Auth token'), /** - * AES-256 key for the local database file; local/replica modes only. - * Placeholder-free since #8336: an unresolved `${…}` here would encrypt - * the database with the literal placeholder string as its key — data - * unreadable under the key the author believed they set. + * Encrypts nothing, in any mode — and is REFUSED in a local or replica + * mode since #25020, and beside a `file:` url or `:memory:` under a forced + * `mode: 'remote'`, whose libSQL client is handed no key (see + * {@link tursoTransportIssues}). This key used to + * promise "AES-256 key for the local database file", but the driver reads + * and writes that file through better-sqlite3, which has no encryption + * codec: a local datasource that declared it was written in plaintext that + * any SQLite reader opened without it (measured on the driver source), and + * an embedded replica fared no better, because only its sync client was + * handed the key. The driver does not forward it to a remote database, + * whose storage encryption is the server's. + * + * Still declared (removing it is a key retirement this refusal does not + * make) and still placeholder-free (#8336); the datasource redaction set + * withholds it on every read (`STILL_WRITABLE_CREDENTIAL_KEYS` in + * `datasource-credential-redaction.ts`). */ encryptionKey: placeholderFree(z.string(), 'encryptionKey').optional() - .describe('AES-256 encryption key for the local database file (local/replica modes)') + .describe("Not honoured in any mode: refused for a local or replica datasource, whose SQLite engine cannot encrypt the file, and for a file: url or :memory: under a forced remote mode, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key") .meta({ title: 'Encryption key', format: 'password' }), /** Max concurrent requests to the remote database (replica/remote modes). */ diff --git a/packages/spec/src/migrations/entries/semantic/18.turso-config-local-encryption-key-refused.ts b/packages/spec/src/migrations/entries/semantic/18.turso-config-local-encryption-key-refused.ts new file mode 100644 index 00000000000..a29a201d953 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.turso-config-local-encryption-key-refused.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A declared encryption nothing applied, refused at both doors together: the +// datasource contract (on encryptionKey) and the turso driver's constructor, in +// one message per mode. The driver used to accept the key and write the local +// or replica file in plaintext. A structured TODO, not a D2 conversion: whether +// the author can live with an unencrypted file, wants disk or volume +// encryption, or wants a remote database is intent no artifact records. +export const entry: SemanticMigration = { + id: 'turso-config-local-encryption-key-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.TursoConfig (a turso / libsql datasource.config), the published TursoConfigSchema mirror and ' + + 'the TursoDriver constructor of @objectstack/driver-turso — a non-empty encryptionKey beside a ' + + 'file: url or :memory: (a local or embedded-replica datasource, with or without syncUrl, and the ' + + 'same url under any forced mode, remote included) is now refused, on encryptionKey at authoring ' + + 'and at construction', + replacement: + 'no key: drop encryptionKey. Nothing in the turso driver encrypts a local or replica file, so a ' + + 'file that must be encrypted at rest lives on an encrypted disk or volume, or the data moves to ' + + 'a remote Turso database, whose storage encryption is the server\'s to configure', + reason: + 'encryptionKey was declared as an AES-256 key for the local database file, and nothing applied ' + + 'it. The turso driver runs every read and write of a local or replica file through better-sqlite3, ' + + 'which has no encryption codec: no codec compile option is set and PRAGMA key is a silent no-op ' + + '(measured on better-sqlite3 13.0.3, SQLite 3.53.4). Measured on the driver source with a test ' + + 'key: a local datasource constructed, connected and wrote its rows, and plain better-sqlite3 ' + + 'opened the file read-only with no key and read them; a replica handed the key to its sync ' + + 'client only, after the local engine had already written the file in plaintext, and a file that ' + + 'client encrypted is one the local engine cannot open. Honouring the key would mean replacing the ' + + 'engine that serves every read and write, so the ruling on this defect refused it instead: a declared ' + + 'protection the runtime does not apply is the declared-but-not-enforced shape ADR-0049 does not ' + + 'ship, and here it left operators believing their data was encrypted. The refusal comes last ' + + 'among the local-engine refusals, so a url, a lone sync or a forced mode that another refusal ' + + 'takes keeps that refusal. An empty encryptionKey is unset and is not refused. The key is judged ' + + 'by where the database lives, not by the mode: a file: url or :memory: under a forced remote ' + + 'mode is refused too, because the driver hands that url to the libSQL client it builds, which is ' + + 'handed no key, so the file was written in plaintext all the same. A remote url (libsql, https or ' + + 'ws) is not judged by this refusal. Stored datasource rows are not re-parsed when they load, so a ' + + 'stored row in this shape now fails when its driver is built: the ' + + 'connection service records it as failed-degraded, a test connection answers ok false, and ' + + 'under ADR-0062 D5 the boot fails fast when objects bind to that datasource. Measured on this ' + + 'tree at the change: no example, ' + + 'template, CLI scaffold or published skill authors the key, and no host default or environment ' + + 'variable sets it. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every turso datasource: os validate or defineStack, and a save ' + + 'or test connection through the datasource admin service, report encryptionKey on a local or ' + + 'replica datasource at config.encryptionKey. Drop the key from each, and where the file must be ' + + 'encrypted at rest, put it on an encrypted disk or volume or move the data to a remote Turso ' + + 'database. Done when every turso datasource parses, the driver builds from it, and no local or ' + + 'replica datasource carries an encryptionKey.', +};