Skip to content

Commit 0833772

Browse files
committed
fix(spec,driver-turso): refuse encryptionKey beside a file: url or :memory: under a forced remote mode too (wip)
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
1 parent d15981a commit 0833772

10 files changed

Lines changed: 192 additions & 41 deletions

File tree

‎.changeset/25020-turso-encryption-key-local.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,34 @@
11
---
22
'@objectstack/spec': minor
33
'@objectstack/driver-turso': minor
4+
'@objectstack/service-datasource': patch
45
---
56

67
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
78

8-
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 in two modes. No key is added, removed or renamed, and no exported symbol moves.
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.
910

1011
`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.
1112

12-
**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:
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:
1314

1415
- **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;
1516
- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any database file is opened.
1617

17-
The message is the same text at all three places, and a test holds the constructor's copy equal to the schema's issue byte for byte. 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 datasource is not judged by this refusal. The `encryptionKey` describe texts no longer promise an encrypted file.
18+
The message is the same text at all three places, and a test holds the constructor's copy equal to the schema's issue byte for byte. 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.
1819

1920
### Migration: FROM → TO
2021

2122
| You wrote | Write instead |
2223
| --- | --- |
2324
| `url: 'file:./data/app.db', encryptionKey: '…'` | drop `encryptionKey`; put the file on an encrypted disk or volume if it must be encrypted at rest |
2425
| `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 |
2527
| 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 |
2628

27-
The fix in one line: delete `encryptionKey` from every local or replica turso datasource.
29+
The fix in one line: delete `encryptionKey` from every turso datasource whose `url` is a `file:` url or `:memory:`.
2830

29-
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, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. Otherwise it is left unconnected with a warning. Before this change the same row booted and wrote its file in plaintext.
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.
3032

3133
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.
3234

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,12 @@ the file was written in plaintext whatever the key said. A local or
238238
embedded-replica turso datasource that declares it is now refused at write
239239
time and when the driver is built: drop the key, and put the file on an
240240
encrypted disk or volume if it must be encrypted at rest. A remote turso
241-
datasource still parses with the key, which the driver does not forward
242-
(a remote database's storage encryption is the server's), and a stored value
243-
is redacted on read like the rest of this table.
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.
244247
</Callout>
245248

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

‎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?` | — | 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; a remote database's storage encryption is the server's, not set by this key |
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) |

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ better-sqlite3, which has no encryption codec, so the key would encrypt nothing
257257
and the file would be written in plaintext that any SQLite reader opens without
258258
it. Drop `encryptionKey`, and put the file on an encrypted disk or volume if it
259259
must be encrypted at rest, or use a remote Turso database, whose storage
260-
encryption is the server's to configure.
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.
261263

262264
You can also force a specific mode:
263265

@@ -337,7 +339,8 @@ interface TursoDriverConfig {
337339

338340
/**
339341
* Encrypts nothing, in any mode: refused in local and replica modes
340-
* (VALIDATION_ERROR / 400), because the local engine cannot encrypt the file.
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'.
341344
*/
342345
encryptionKey?: string;
343346

‎packages/drivers/driver-turso/src/spec/turso.zod.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,32 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] {
324324
+ "file beside `syncUrl`: `url: 'file:./data/replica.db'`.",
325325
});
326326
}
327+
if (
328+
(isFileTursoUrl(url) || url === ':memory:')
329+
&& typeof cfg.encryptionKey === 'string' && cfg.encryptionKey.length > 0
330+
) {
331+
// #25020, patch round 1. Only a FORCED remote mode reaches here with a
332+
// `file:` url or `:memory:` (with no `mode`, those urls select a local
333+
// engine). The key is judged by where the database lives, not by the
334+
// mode: `connect()` hands that url to the libSQL client this driver
335+
// builds, which is handed no key, so the issue sits on `encryptionKey`,
336+
// last, like the local-engine arm's.
337+
issues.push({
338+
path: 'encryptionKey',
339+
message: namesInMemoryTursoDatabase(url)
340+
? "`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory "
341+
+ "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this "
342+
+ 'driver builds, which is handed no key: there is no file for the key to encrypt, so it would change '
343+
+ 'nothing — the turso driver refuses this configuration when it starts. Drop `encryptionKey`.'
344+
: "`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local "
345+
+ "`file:` url, and `mode: 'remote'` runs every read and write of that file through the libSQL client "
346+
+ 'this driver builds, which is handed no key: nothing would encrypt the file, so it would be written '
347+
+ 'in plaintext that any SQLite reader opens without the key — the turso driver refuses this '
348+
+ 'configuration when it starts. Nothing in this driver encrypts a local file. Drop `encryptionKey`, '
349+
+ 'and encrypt the disk or volume the file lives on instead, or point `url` at a remote Turso '
350+
+ "database, whose storage encryption is the server's to configure, not this key's.",
351+
});
352+
}
327353
return issues;
328354
}
329355

@@ -347,8 +373,11 @@ export const TursoConfigSchema = lazySchema(() => z.object({
347373

348374
/**
349375
* Encryption Key — encrypts nothing, in any mode, and is REFUSED for a local
350-
* or embedded-replica database (#25020; the refusal is the
351-
* `tursoTransportIssues` arm above, in the spec contract's words).
376+
* or embedded-replica database, and beside a `file:` url or `:memory:` under
377+
* a forced `mode: 'remote'` (#25020; the refusals are the
378+
* `tursoTransportIssues` arms above, in the spec contract's words — this
379+
* mirror declares no `mode`, so it judges such a config as the local
380+
* database its url selects, and refuses it with that arm's text).
352381
*
353382
* It used to promise to encrypt the local database file at rest with
354383
* AES-256. The driver reads and writes that file through better-sqlite3,
@@ -357,7 +386,7 @@ export const TursoConfigSchema = lazySchema(() => z.object({
357386
* its sync client only. The driver does not forward it to a remote database
358387
* either, whose storage encryption is the server's.
359388
*/
360-
encryptionKey: z.string().optional().describe("Not honoured in any mode: refused for a local or replica datasource, whose SQLite engine cannot encrypt the file; a remote database's storage encryption is the server's, not set by this key"),
389+
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"),
361390

362391
/**
363392
* Concurrency Limit.

0 commit comments

Comments
 (0)