Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/25020-turso-encryption-key-local.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered turso-config-local-encryption-key-refused -->
25 changes: 19 additions & 6 deletions content/docs/data-modeling/drivers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -222,15 +222,28 @@ like the rest of this table.
<Callout type="warn">
**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.
</Callout>

## Startup: a driver that cannot connect aborts the boot
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-turso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
2 changes: 1 addition & 1 deletion docs/design/driver-turso.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
4 changes: 2 additions & 2 deletions docs/qa/platform-checklist/areas/integration-system.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -1730,7 +1730,7 @@
"read the SAME datasource back through the metadata door (GET /api/v1/meta/datasource/<name>) — 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": [
Expand Down
16 changes: 14 additions & 2 deletions packages/drivers/driver-turso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading