Skip to content

Commit 28c9a2c

Browse files
fix(spec,driver-turso)!: refuse encryptionKey on a local or replica turso datasource instead of writing the file in plaintext (#25045)
Fixes #25020 Clause-②: no (narrowing) The `Clause-②` line above is the claim's (comment 6306742426: "If the measured route is the refusal, it is `no (narrowing)`"), and the measured route is the refusal. The changeset carries the same value. Under the claim, this PR owes a `CONTRACT_REVIEW_TIER` review before the queue. Dev run of the `domain:engine` seat 2 PM dispatch (session `session_01QK6pdbxKZiHdFApL8RYVh9`, `mode:subagent`), branch `claude/issue-25020-turso-encryption-key-local`, BASE `4d991588f`, with one true merge of `origin/main` at `d15981a07` (it touched no file of this diff). **Patch round 1** (contract review 6308116467, FAIL item 1 and findings 7–9; seat verdict 6308149967) landed as `0833772a4`, `b0236c662` and `4a595ed43`. **Patch round 2** (seat order 6310662118) landed as `df6803d15`: one changeset sentence, `.changeset/**` only. **Every reading below was taken at head `4a595ed43`** unless it says otherwise (the full dogfood battery, the derived gates and the check-runs were re-read at `df6803d15`); the H1 table and the H2 grep were read at BASE and at `d15981a07`, and nothing since touched what they read. Only test values appear here (`test-key-25020`); no real key and no production path. ## The premise, measured first: it does not hold The seat's premise P was: the key can be honoured by handing it to the engine `driver-turso` already uses for the file's reads and writes, with no engine swap and no new dependency. Measured: - **The engine is better-sqlite3 13.0.3 (SQLite 3.53.4), and it has no encryption codec.** `PRAGMA compile_options` lists no codec option. `PRAGMA key = 'test-key-25020'`, `PRAGMA cipher`, `PRAGMA cipher_version` and `PRAGMA rekey` all answer `[]` (an unknown pragma is a silent no-op). A table written after `PRAGMA key` leaves a file whose header is `SQLite format 3`, and a second, key-less read-only open reads the row back. - **The native libSQL binding does encrypt** (`@libsql/client` 0.18.0 over `libsql` 0.5.29, already a dependency). A local `file:` client with the key writes a file whose header is not `SQLite format 3`; better-sqlite3 opening it answers `SQLITE_NOTADB`; a libSQL client reopening it without the key answers `SQLITE_NOTADB`; with the key it reads the row. But that binding is not the engine this driver reads and writes through, so honouring the key means replacing the Knex / better-sqlite3 path with another engine. That is the ruling's "P does not hold" branch. So the route is the refusal. No engine swap, no partial swap, no warn-and-ignore. ## What the driver did with the key, per mode (H1), before this change Measured on `TursoDriver` from source at BASE, with a test key: | Mode | What happened | | --- | --- | | local (`file:` url + key) | constructs, connects, `syncSchema` + `create` + `find` round-trip; file header `SQLite format 3`; plain better-sqlite3, read-only, no key, reads the row | | replica, supplied client (`file:` + `syncUrl` + `client`) | the same: the key reaches nothing | | replica, driver-built sync client (`file:` + `syncUrl`, unreachable local sync endpoint) | `super.connect()` writes the file in plaintext first (`db.db`, `-shm`, `-wal`, header `SQLite format 3`); only then is `createClient` handed the key, and its construction fails to connect (`sync error: … tcp connect error`) | | `@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`) | So both halves of H1 hold, depending on which side opens the file with the key: the engine that serves every read and write never encrypts it, and a file the sync client encrypted is one that engine cannot open. **NOT MEASURED: a replica with the key against a reachable sync server.** No libSQL sync server runs in this container, and the native binding's default (non-offline) sync path speaks the server's replication protocol, which nothing here can stand in for. The refusal does not depend on it: it fires at construction, before any client is built. ## Who declares the key today (H2) Measured on this tree at `d15981a07`: `git grep` over `examples/**`, `packages/create-objectstack`, `packages/cli/src` (non-test) and `skills/**` finds **zero** `encryptionKey` authors; no environment variable (`TURSO_ENCRYPTION_KEY`, `OS_DATABASE_ENCRYPTION*`) or host default sets it. The hits are the declarations themselves, docs describing them (`content/docs/data-modeling/drivers.mdx`, `docs/design/driver-turso.md`, the driver README), and test fixtures. Of the fixtures, the redaction suites author it on remote `libsql://` urls (unaffected), and two `metadata-protocol` suites carry it on a `file:` url in STORED rows that no parse reads (run green, below). ## What changes 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 at all three places a turso config is judged, with one message per mode: - **Construction.** `new TursoDriver()` (and `createTursoDriver()`) throws `VALIDATION_ERROR` / 400 before `super()`, so no file is opened. It is the last of the local-engine refusals in the constructor, thrown through the existing envelope helper. The message comes from `localEncryptionKeyRefusal(mode)` in `packages/drivers/driver-turso/src/turso-driver.ts`. - **Authoring.** `tursoTransportIssues` in `packages/spec/src/data/driver/turso.zod.ts` gains a last arm in the local / replica branch: one `custom` issue on `encryptionKey`. It reaches `DatasourceSchema` (as `config.encryptionKey`), `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service. - **The driver mirror** (`packages/drivers/driver-turso/src/spec/turso.zod.ts`) carries the same arm byte for byte. **Patch round 1: the key is judged by where the database lives, not by the mode.** A forced `mode: 'remote'` answers first in `detectMode`, so `{ url: 'file:…', mode: 'remote', encryptionKey }` skipped every local-engine refusal, and `connect()` handed the local url to `createRemoteClient()`, which builds `createClient({ url, authToken, concurrency })` with no key. Measured on this head with the new constructor arm ablated (leg D below): it constructed and connected in `remote` mode, `syncSchema` + `create` + `find` round-tripped, the file's header read `SQLite format 3`, and plain better-sqlite3, read-only, with no key, read the row. With the arm: `VALIDATION_ERROR` / 400 at construction, and the file does not exist afterwards. Now refused at all three places: - **Construction.** One mode-independent predicate before `super()`: a non-empty key beside a url that `namesLocalDatabase()` (a `file:` url in any letter case, or `:memory:`, the same scheme fold the driver uses everywhere). In a local or replica mode it throws the existing text (every url reaching that point is already a `file:` url or `:memory:`, because `localEngineDefect` refused the rest); under a forced remote mode it throws `remoteModeLocalEncryptionKeyRefusal(url)`. It runs after every forced-remote refusal, so a `syncUrl` or a lone `sync` beside it keeps its own message. - **Authoring.** `tursoTransportIssues` gains a last arm in its remote branch: a `file:` url or `:memory:` beside a non-empty key raises one `custom` issue on `encryptionKey`, after the `timeoutMs` and `syncUrl` issues. - **The mirror** carries the arm byte for byte. It declares no `mode`, so zod strips a forced one and the mirror judges `{ url: 'file:…', mode: 'remote', encryptionKey }` as the local database its url selects: it refuses it on `encryptionKey`, in the local arm's text. Its copy of the remote arm is unreachable through it and is kept for copy parity, like its other forced-mode branches. The parity table therefore judges the forced-remote rows by the constructor and the spec contract (verdict, key, and byte equality of the constructor's message with the spec's), and skips the mirror's verdict for them, as it does for every forced-mode row. Two texts, one per variant; both name the mode, echo no url and no key, and keep the envelope: > `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. > `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`. The key is not forwarded to `createClient` in this shape: honouring it there would make a file's encryption depend on a transport override, a capability nothing pulls, and #25050 proposes retiring the key. A remote url (`libsql://`, `https://`, `ws(s)://`) with a key is still accepted; that mode is #25050's. The message (local; the replica variant says "an embedded replica" and adds the parenthesis "the sync client shares that one file, and a file it encrypted could not be opened by that engine"): > `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. 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. It names the mode and what does encrypt. It names the datasource the way its siblings do ("this datasource"): the driver is never handed the datasource's name, and the boot path prefixes every build failure with `datasource 'NAME': connect failed —` (`datasource-connection-service.ts`), while the authoring issue sits at that datasource's `config.encryptionKey`. It echoes no url and no key. **Order.** A url the engine cannot open, a lone `sync`, a forced replica with no `syncUrl`, a forced local mode beside a `syncUrl`, and under a forced remote mode a `syncUrl`, each keep their own refusal. A lone `sync` or a forced-remote `syncUrl` stays the spec's first issue, with the key's second. An empty `encryptionKey` is unset, as the loaders read it (`buildTursoDriverConfig` drops it), and is still accepted. A remote url is not judged (the claim puts the `libsql://` mode out of scope; see Acceptance notes). **Text made true.** The `encryptionKey` describe in both schemas, its TSDoc, `TursoDriverConfig.encryptionKey`, the README, `docs/design/driver-turso.md` §10, the `content/docs/data-modeling/drivers.mdx` still-writable trade-off (turso's key left it: it was never "a capability the client genuinely honours"), the `STILL_WRITABLE_CREDENTIAL_KEYS` comment and (patch round 1) the module header of `datasource-credential-redaction.ts`, the TSDoc of `@objectstack/service-datasource`'s exported `TursoDriverConfigInput.encryptionKey`, and the QA checklist's turso expectation (write-accepted only on a remote datasource now). Patch round 1 also makes the describe in both schemas, the regenerated reference, `drivers.mdx`'s remote sentence, the README and the design doc true for a `file:` url or `:memory:` under a forced remote mode. The replica arm of `connect()` stops forwarding `encryptionKey` to `createClient`: a non-empty one can no longer reach it. The generated `content/docs/references/data/driver-turso.mdx` is regenerated (`check:generated --fix`, the one stale artifact; in patch round 1 again, then `gen:schema`, `gen:docs` and `check:docs` in that order, exit 0 at `4a595ed43`: "227 generated files in sync with packages/spec"). **Migration.** `packages/spec/src/migrations/entries/semantic/18.turso-config-local-encryption-key-refused.ts` (ADR-0087 semantic entry), registered in the changeset's ADR-0087 marker as `registered turso-config-local-encryption-key-refused`. The changeset `.changeset/25020-turso-encryption-key-local.md` is `minor` for `@objectstack/spec` and `@objectstack/driver-turso`, BREAKING, with the FROM / TO table (a forced-remote row added in patch round 1) and the one-line fix: delete `encryptionKey` from every turso datasource whose `url` is a `file:` url or `:memory:`. `@objectstack/service-datasource` is graded `patch` for its TSDoc: `check-changeset-no-major.mjs --base origin/main --event` (this body's `Clause-②` line) answered exit 0, "@objectstack/service-datasource: patch … NOT refused". A stored row in that shape now fails when its driver is built (`failed-degraded`, ADR-0062 D5). Patch round 1 dropped the connect-failure escape hatch from the entry's `reason` and from the changeset's ADR-0062 D5 sentence. ## Pins - `packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts` (new): local (`file:`, `FILE:`, forced `mode: 'local'`, `:memory:`, `createTursoDriver`) and replica (driver-built client, supplied client, forced `mode: 'replica'`) refusals as `code` + `status` + the mode's first sentence, each asserting no database file exists afterwards; the message names no url and no key. Controls: a local datasource with no key, one with an empty key, and a replica with no key each construct, connect, write and read back through the driver, and the file is a plain SQLite database with the row. - `packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts`: 16 new rows (8 refused on `encryptionKey`, 6 order rows, 2 empty-key accept rows); the byte-equality set now includes `encryptionKey`; floors at the measured counts (8 key rows, 3 of them replica, 31 copied-message rows). - `packages/spec/src/data/driver/turso.test.ts`: the authoring refusal per mode, no echo, order (including the two-issue `sync` + key case), both authoring doors, controls, and the describe no longer promising AES-256. - **Patch round 1.** Refusal file: `file:`, `FILE:` and `file:` + `timeout` under a forced remote mode (`code`, `status`, the first sentence, no file afterwards), `:memory:` and `file::memory:` (the in-memory sentence), `createTursoDriver` with no echo; controls: the same shape with no key, with an empty key, `:memory:` with no key, and a `libsql://` url with a key (auto-detected and forced remote) each construct in remote mode. Parity table: 5 rows refused on `encryptionKey` under a forced remote mode, 2 order rows (`syncUrl` first, a lone `sync` first, each two issues at the spec), 4 accept controls (`:memory:` and an empty key under a forced remote mode, `libsql://` with a key auto-detected and forced); floors 13 key rows, 5 of them forced-remote, 38 copied-message rows. Spec test: the file and in-memory variants, no echo, order, both doors, controls, and the describe naming the forced remote mode. - Fixture triage: `driver-turso/src/spec/turso.test.ts`'s all-fields replica fixture drops the key (it pinned the branch this removes); `driver-placeholder-refusal.test.ts`'s turso `encryptionKey` row moves to a remote url, the one mode where a literal still parses, with a comment that it pins the placeholder judgement and not a remote mode honouring the key. ## Reverse verification (ablation), direction predicted before it ran Each leg went through `scripts/ablation-replace.mjs` on the committed head (anchor 1 to 0, blob changed, restore proven: blob equals HEAD and `git diff HEAD` empty). All three suites resolve the mutated file from source, so no rebuild was needed. | Leg | Mutation | Run | Result | | --- | --- | --- | --- | | A | constructor: the refusal call replaced by a no-op | the new refusal file + the parity table | **25 failed** / 249 passed / 37 skipped: all 9 refusal cases and 16 parity cases (8 key rows times constructor verdict and byte equality) red; the 3 controls and every order row green | | B | spec: the `encryptionKey` arm disabled | `spec/src/data/driver/turso.test.ts` + the placeholder family | **5 failed** / 123 passed: the 5 refusal cases red; controls, the describe pin and the placeholder family green | | C | mirror: the same arm disabled | the parity table + the mirror's own test | **7 failed** / 287 passed / 37 skipped: the mirror verdict on the 6 unforced key rows and the `sync` + key row red; forced-mode rows skip the mirror by design | | D (patch round 1, at `4a595ed43`) | constructor: the new predicate narrowed back to the old guard (`mode !== 'remote' && …`) | the refusal file + the parity table, plus the door-6 probe | predicted: the forced-remote refusal cases red, everything else green. **16 failed** / 299 passed / 47 skipped: the 6 forced-remote refusal cases (3 file, 2 in-memory, `createTursoDriver`) and 10 parity cases (5 rows times constructor verdict and byte equality) red; every control, every local / replica case and both forced-remote order rows green. The probe under the mutation reproduced door 6 (plaintext file, read with no key) | Not ablated: the parity table's spec half through `packages/spec/dist` (each leg would need a spec rebuild); leg B ablates the same arm through source. The new remote arm in the spec and the mirror is not ablated in patch round 1 (the dispatch asked for one leg, over the constructor arm); the mirror's copy is unreachable through the mirror, so no run could turn it red. ## Tests (head `4a595ed43`) - `@objectstack/driver-turso`: `tsc --noEmit` exit 0; `vitest run`: Test Files 93 passed (93), Tests 2538 passed, 47 skipped. - `@objectstack/spec`: `typecheck` exit 0 (with `check:scripts-typecheck` and `check:test-typecheck`); `vitest run --project local`: Test Files 703 passed (703), Tests 21383 passed, 1 todo. `check:generated --fix`: the one stale artifact (`check:docs`) regenerated; `check:docs` exit 0. - `@objectstack/service-datasource` (imports the changed TSDoc file): `tsc --noEmit` exit 0; `vitest run`: Test Files 51 passed (51), Tests 916 passed. - At `d15981a07`: `metadata-protocol` stored-row suites (2 files, 44 passed). `test:repo` is CI's (Test Core). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 121 families at `4a595ed43` (two more than round 0: the `check-tenant-audit-census` pair). All 121 were run once after a full `turbo run build`, each exit code captured to a file before any pipe: all 121 answered 0. `--ran` reconciliation: "121 derived, 121 run, 0 NOT-MEASURED, 0 UNRUN". `check:driver-conformance` reads "55 covered cell(s), 0 in the DEBT ledger, 0 exempt" at BASE, at `d15981a07` and at `4a595ed43`. The local-only `check-dev-prereqs` scan: exit 0. The PR's check-runs on `4a595ed43`, read once: 36 success, 6 skipped. **Full dogfood battery** (the title carries `!` and the line carries `(narrowing)`), once, on the final head `df6803d15`, closure built per ① and run under the lock as `pnpm --filter @objectstack/dogfood test` with no shard and no path: `VERDICT command-exit 0`, Test Files 284 passed, 3 skipped (287), Tests 2361 passed, 18 skipped. No failure. (At `4a595ed43` the same battery also read `VERDICT command-exit 0`, 284 passed / 3 skipped, 2361 passed / 18 skipped.) At `df6803d15` the 121 derived gate families all exit 0 (`--ran`: 121 derived, 121 run, 0 NOT-MEASURED, 0 UNRUN), and the check-runs read 36 success, 6 skipped. ## Acceptance notes - **A remote url still accepts the key and drops it (not this card's; the claim puts the `libsql://` mode out of scope; filed as #25050).** Measured: `new TursoDriver({ url: 'libsql://db.example.invalid', encryptionKey: 'test-key-25020' })` constructs and connects in `remote` mode; `createRemoteClient` never forwards the key; `@libsql/client`'s own remote client refuses that key (`ENCRYPTION_KEY_NOT_SUPPORTED: Encryption key is not supported by the remote client.`). The spec contract parses it on a remote url. After this PR the key is honoured in no mode, which the describes now say. A local url under a forced remote mode is not this case: it is refused (patch round 1). - The `encryptionKey` key stays declared (and redacted on read) because removing it is a key retirement this refusal does not make. --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4feccc2 commit 28c9a2c

17 files changed

Lines changed: 996 additions & 51 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/driver-turso': minor
4+
'@objectstack/service-datasource': patch
5+
---
6+
7+
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
8+
9+
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.
10+
11+
`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.
12+
13+
**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:
14+
15+
- **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;
16+
- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any database file is opened.
17+
18+
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.
19+
20+
### Migration: FROM → TO
21+
22+
| You wrote | Write instead |
23+
| --- | --- |
24+
| `url: 'file:./data/app.db', encryptionKey: '…'` | drop `encryptionKey`; put the file on an encrypted disk or volume if it must be encrypted at rest |
25+
| `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io', encryptionKey: '…'` | drop `encryptionKey`, the same way |
26+
| `url: 'file:./data/app.db', mode: 'remote', encryptionKey: '…'` (or `url: ':memory:'`) | drop `encryptionKey`, the same way |
27+
| 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 |
28+
29+
The fix in one line: delete `encryptionKey` from every turso datasource whose `url` is a `file:` url or `:memory:`.
30+
31+
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.
32+
33+
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.
34+
35+
<!-- adr-0087: registered turso-config-local-encryption-key-refused -->

‎content/docs/data-modeling/drivers.mdx‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ ObjectStack — is what makes them secrets:
195195
| `mongodb` | `options.key` | TLS private key material (PEM) |
196196
| `mongodb` | `options.passphrase` | TLS key passphrase |
197197
| `mongodb` | `options.authMechanismProperties.AWS_SESSION_TOKEN` | AWS STS session token (`MONGODB-AWS` auth) |
198-
| `turso` \| `libsql` | `encryptionKey` | AES-256 key for the local database file |
198+
| `turso` \| `libsql` | `encryptionKey` | A key the driver never applies (see below) |
199199

200200
Unlike `password` / `authToken` (typed `never` in their schemas — the parse
201201
refuses them outright), these six keys are **writable**: the parse accepts
@@ -222,15 +222,28 @@ like the rest of this table.
222222
<Callout type="warn">
223223
**This is a deliberate, documented trade-off ([#9124](https://github.com/objectstack-ai/objectstack/issues/9124)), not an oversight.**
224224
The binder has exactly one named slot. Refusing `proxyPassword`,
225-
`tlsCertificateKeyFilePassword`, `key`, `passphrase`, or turso's
226-
`encryptionKey` at write time would remove the only way to configure an
227-
authenticated SOCKS5 proxy, a passphrase-protected TLS key, or a
228-
locally-encrypted database file — a capability the client genuinely honours,
225+
`tlsCertificateKeyFilePassword`, `key` or `passphrase` at write time would
226+
remove the only way to configure an authenticated SOCKS5 proxy or a
227+
passphrase-protected TLS key — a capability the client genuinely honours,
229228
with no working refusal remedy. (`AWS_SESSION_TOKEN` isn't part of that
230229
trade-off — see above.) **Restart condition:** the first real deployment
231230
that needs an authenticated proxy or a passphrase-protected key converts
232231
that group into named binder-slot support — one mechanism covering those
233-
five keys — rather than the current per-key accept-and-document posture.
232+
four keys — rather than the current per-key accept-and-document posture.
233+
234+
Turso's `encryptionKey` is no longer part of that trade-off either. It was
235+
declared as an AES-256 key for the local database file, but the driver reads
236+
and writes that file through better-sqlite3, which has no encryption codec, so
237+
the file was written in plaintext whatever the key said. A local or
238+
embedded-replica turso datasource that declares it is now refused at write
239+
time and when the driver is built: drop the key, and put the file on an
240+
encrypted disk or volume if it must be encrypted at rest. A remote turso
241+
datasource (a `libsql://`, `https://` or `ws(s)://` url) still parses with the
242+
key, which the driver does not forward (a remote database's storage encryption
243+
is the server's), while a `file:` url or `:memory:` under a forced
244+
`mode: 'remote'` is refused like a local one, because the libSQL client the
245+
driver builds for it is handed no key; a stored value is redacted on read like
246+
the rest of this table.
234247
</Callout>
235248

236249
## Startup: a driver that cannot connect aborts the boot

‎content/docs/references/data/driver-turso.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Turso / libSQL Connection Configuration
6868
| :--- | :--- | :--- | :--- |
6969
| **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: |
7070
| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` |
71-
| **encryptionKey** | `string` | optional | AES-256 encryption key for the local database file (local/replica modes) |
71+
| **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 |
7272
| **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database |
7373
| **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint |
7474
| **sync** | `{ intervalSeconds?: integer; onConnect?: boolean }` | optional | Embedded-replica sync configuration (requires `syncUrl`) |

‎docs/design/driver-turso.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ carries the prescription. The live keys:
523523
|:---|:---|:---:|:---|
524524
| `url` | `string` | (required) | Database URL (`libsql://`, `https://`, `file:`, `:memory:`) — in replica mode, also the local file |
525525
| `authToken` | `string?` | — | JWT auth token for remote databases |
526-
| `encryptionKey` | `string?` | — | AES-256 encryption key for local files |
526+
| `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 |
527527
| `concurrency` | `number` | `20` | Maximum concurrent requests |
528528
| `syncUrl` | `string?` | — | Remote sync URL for embedded replica mode |
529529
| `sync.intervalSeconds` | `number` | `60` | Periodic sync interval (0 = manual only) |

‎docs/qa/platform-checklist/areas/integration-system.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,7 +1716,7 @@
17161716
],
17171717
"knownGaps": [
17181718
"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.",
1719-
"`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.",
1719+
"`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.",
17201720
"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",
17211721
"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",
17221722
"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 @@
17301730
"read the SAME datasource back through the metadata door (GET /api/v1/meta/datasource/<name>) — a different code path with its own redaction hook",
17311731
"plant a stored row carrying a legacy alias spelling (passwd / pwd / token / jwt / auth_token / authtoken) and read it back through both doors",
17321732
"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",
1733-
"for turso specifically, write `encryptionKey`, then read it back through both doors",
1733+
"for turso specifically, write `encryptionKey` on a remote (libsql:// or https://) turso datasource, then read it back through both doors",
17341734
"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)"
17351735
],
17361736
"acceptance": [

‎packages/drivers/driver-turso/README.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,17 @@ that nothing would honour, each with the message `@objectstack/spec`'s
250250
would be ignored. Drop `mode` for an embedded replica, or drop `syncUrl` (and
251251
`sync`) for a plain local database.
252252

253+
It refuses (`VALIDATION_ERROR` / 400) a non-empty `encryptionKey` on a local
254+
database or an embedded replica too, in the same message the spec contract
255+
gives at authoring. The driver reads and writes that file through
256+
better-sqlite3, which has no encryption codec, so the key would encrypt nothing
257+
and the file would be written in plaintext that any SQLite reader opens without
258+
it. Drop `encryptionKey`, and put the file on an encrypted disk or volume if it
259+
must be encrypted at rest, or use a remote Turso database, whose storage
260+
encryption is the server's to configure. A `file:` url or `:memory:` under a
261+
forced `mode: 'remote'` is refused the same way: the libSQL client the driver
262+
builds for that url is handed no key.
263+
253264
You can also force a specific mode:
254265

255266
```typescript
@@ -327,8 +338,9 @@ interface TursoDriverConfig {
327338
authToken?: string;
328339

329340
/**
330-
* AES-256 encryption key for local database file.
331-
* Only effective in local/replica modes.
341+
* Encrypts nothing, in any mode: refused in local and replica modes
342+
* (VALIDATION_ERROR / 400), because the local engine cannot encrypt the file,
343+
* and beside a file: url or :memory: under a forced mode: 'remote'.
332344
*/
333345
encryptionKey?: string;
334346

0 commit comments

Comments
 (0)