From 32c3aa0c24ff9f6c8e8044be8a76ef623b8d09a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:13:37 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(service-settings):=20publish=20ciphert?= =?UTF-8?q?extDerivationStatus,=20the=20provider's=20own=20reading=20of=20?= =?UTF-8?q?a=20stored=20ciphertext's=20AAD=20derivation=20(ADR-0128=20?= =?UTF-8?q?=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The at-rest re-wrap must tell a version-1 row from one already sealed under the current derivation without opening it, and must not restate the marker grammar that decrypt dispatches on. The reading is the provider's own readCiphertext, published from the package root. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../services/service-settings/src/index.ts | 5 +++ .../src/local-crypto-provider.test.ts | 43 +++++++++++++++++++ .../src/local-crypto-provider.ts | 43 +++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index f82f1738f6f..5757c9bb5b1 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -27,6 +27,11 @@ export { type LocalCryptoProviderOptions, type CryptoMode, type KeySource, + // ADR-0128 §4.2 — the provider's own reading of which AAD derivation sealed a + // stored ciphertext, read off its marker without opening it. Published so + // `os secret rewrap` classifies rows with this grammar, not a restated one. + ciphertextDerivationStatus, + type CiphertextDerivationStatus, } from './local-crypto-provider.js'; export { type SettingsActionHandler, diff --git a/packages/services/service-settings/src/local-crypto-provider.test.ts b/packages/services/service-settings/src/local-crypto-provider.test.ts index 38c919c7bde..1a2550916a9 100644 --- a/packages/services/service-settings/src/local-crypto-provider.test.ts +++ b/packages/services/service-settings/src/local-crypto-provider.test.ts @@ -18,6 +18,7 @@ import { CryptoContextScopeError, UnknownCiphertextVersionError, aadForVersion2, + ciphertextDerivationStatus, } from './local-crypto-provider.js'; const ctx: CryptoContext = { scope: 'settings', namespace: 'mail', key: 'api_key' }; @@ -340,6 +341,48 @@ describe('LocalCryptoProvider — scoped, versioned AAD (ADR-0128)', () => { expect(await p.decrypt(sealed, at('settings'))).toBe('x'); expect((await p.rotateKey(sealed, at('settings'))).ciphertext.startsWith('v2:')).toBe(true); }); + + /** + * ADR-0128 §4.2 — the reading the at-rest re-wrap classifies rows with. It + * must agree with what `decrypt` dispatches on, so each status is pinned + * against the provider's own behaviour on the same bytes, not against a + * spelling of the marker. + */ + it('ciphertextDerivationStatus reads the marker decrypt dispatches on, without opening anything', async () => { + const p = new LocalCryptoProvider({ key: PINNED_KEY }); + const legacyCtx = at('settings', 'legacy_ns', 'legacy_key'); + + // Version 1: opens today, and rotateKey moves it to the current derivation. + expect(ciphertextDerivationStatus(LEGACY_HANDLE.ciphertext)).toBe('superseded'); + expect(await p.decrypt(LEGACY_HANDLE, legacyCtx)).toBe(LEGACY_PLAIN); + const rotated = await p.rotateKey(LEGACY_HANDLE, legacyCtx); + expect(ciphertextDerivationStatus(rotated.ciphertext)).toBe('current'); + + // Version 2: the pinned vector and every fresh seal are current. + expect(ciphertextDerivationStatus(V2_HANDLE.ciphertext)).toBe('current'); + for (const scope of CRYPTO_CONTEXT_SCOPES) { + expect(ciphertextDerivationStatus((await p.encrypt('x', at(scope))).ciphertext)).toBe('current'); + } + + // Unknown: exactly the bytes decrypt refuses as an unknown derivation. + const unknown = { ...V2_HANDLE, ciphertext: 'v3:' + V2_HANDLE.ciphertext.slice(3) }; + expect(ciphertextDerivationStatus(unknown.ciphertext)).toBe('unknown'); + await expect(p.decrypt(unknown, V2_CTX)).rejects.toBeInstanceOf(UnknownCiphertextVersionError); + for (const notAString of [undefined, null, 42, { ciphertext: V2_HANDLE.ciphertext }]) { + expect(ciphertextDerivationStatus(notAString)).toBe('unknown'); + } + }); + + it('ciphertextDerivationStatus is a statement about the marker, not a promise that the row opens', async () => { + // A version-1 body sealed under ANOTHER key still reads `superseded`: only + // opening it under its producer's context can say whether it is readable, + // which is why the re-wrap opens every row before it writes one. + const other = new LocalCryptoProvider({ key: randomBytes(32) }); + expect(ciphertextDerivationStatus(LEGACY_HANDLE.ciphertext)).toBe('superseded'); + await expect( + other.decrypt(LEGACY_HANDLE, at('settings', 'legacy_ns', 'legacy_key')), + ).rejects.toThrow(); + }); }); describe('LocalCryptoProvider — keyedDigest', () => { diff --git a/packages/services/service-settings/src/local-crypto-provider.ts b/packages/services/service-settings/src/local-crypto-provider.ts index 77423577310..039c1efa358 100644 --- a/packages/services/service-settings/src/local-crypto-provider.ts +++ b/packages/services/service-settings/src/local-crypto-provider.ts @@ -203,6 +203,9 @@ export class KeyedDigestKeyUnavailableError extends Error { /** The AAD derivations this provider knows (see "AAD binding" above). */ type AadDerivation = 1 | 2; +/** The derivation every seal uses — the one a re-wrap leaves a ciphertext under. */ +const SEALING_DERIVATION: AadDerivation = 2; + /** Separates a ciphertext's derivation marker from its base64 body. */ const CIPHERTEXT_MARKER_SEPARATOR = ':'; @@ -323,6 +326,46 @@ function readCiphertext(ciphertext: string): { derivation: AadDerivation; body: throw new UnknownCiphertextVersionError(marker); } +/** + * What a stored ciphertext records about the AAD derivation that sealed it, + * relative to the one this provider seals with. See + * {@link ciphertextDerivationStatus}. + * + * - `'current'` — sealed under the derivation every new seal uses (version + * 2). A re-wrap has nothing to do. + * - `'superseded'` — sealed under a derivation this provider still opens but + * no longer seals with (version 1, no marker). + * {@link LocalCryptoProvider.rotateKey} re-seals it under the current one. + * - `'unknown'` — a marker this provider does not know, or a value that is not + * a ciphertext string at all. `decrypt` and `rotateKey` refuse it. + */ +export type CiphertextDerivationStatus = 'current' | 'superseded' | 'unknown'; + +/** + * Read a stored ciphertext's derivation off its marker, WITHOUT opening it: no + * key and no context are involved, so nothing is decrypted. + * + * It is the same reading `decrypt` and `rotateKey` dispatch on + * (`readCiphertext`), published so that the at-rest re-wrap (ADR-0128 §4.2) + * classifies stored rows with this provider's own grammar. ⛔ A consumer that + * restated the marker grammar would drift from that dispatch, and the drift + * shows up as a row skipped as done that was never re-wrapped. + * + * `'superseded'` is a statement about the marker only. Whether the ciphertext + * really opens is known only by opening it under its producer's context. + */ +export function ciphertextDerivationStatus(ciphertext: unknown): CiphertextDerivationStatus { + if (typeof ciphertext !== 'string') return 'unknown'; + let derivation: AadDerivation; + try { + derivation = readCiphertext(ciphertext).derivation; + } catch (error) { + if (error instanceof UnknownCiphertextVersionError) return 'unknown'; + throw error; + } + return derivation === SEALING_DERIVATION ? 'current' : 'superseded'; +} + type EnvMap = Record; /** Where the provider resolved its data key from (for diagnostics). */ From 4c8d2fa8821d7145490f4ea9e270fbbf276133ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:25:06 +0000 Subject: [PATCH 2/9] =?UTF-8?q?feat(cli):=20os=20secret=20rewrap,=20the=20?= =?UTF-8?q?at-rest=20re-wrap=20of=20version-1=20sys=5Fsecret=20ciphertext?= =?UTF-8?q?=20(ADR-0128=20=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- packages/cli/src/commands/secret/rewrap.ts | 348 +++++++++++++ packages/cli/src/utils/sys-secret-rewrap.ts | 540 ++++++++++++++++++++ 2 files changed, 888 insertions(+) create mode 100644 packages/cli/src/commands/secret/rewrap.ts create mode 100644 packages/cli/src/utils/sys-secret-rewrap.ts diff --git a/packages/cli/src/commands/secret/rewrap.ts b/packages/cli/src/commands/secret/rewrap.ts new file mode 100644 index 00000000000..6f3d765d766 --- /dev/null +++ b/packages/cli/src/commands/secret/rewrap.ts @@ -0,0 +1,348 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Command, Flags } from '@oclif/core'; +import { createInterface } from 'node:readline'; +import chalk from 'chalk'; +import { + printHeader, + printSuccess, + printWarning, + printError, + printInfo, + printStep, + createTimer, + emitJson, + errorCodeFields, + isExitSignal, +} from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import type { + DatasourceArtefactLike, + SecretReferenceEngineLike, +} from '../../utils/secret-reference-union.js'; +import type { + RewrapProviderLike, + RewrapSecretRow, + SysSecretRewrapReport, +} from '../../utils/sys-secret-rewrap.js'; +import { readDeclaredDatasources } from './orphans.js'; + +/** + * `os secret rewrap` — the at-rest re-wrap of version-1 `sys_secret` + * ciphertext, ADR-0128 §4.2. + * + * Every new seal binds its producer's scope into a versioned AAD (ADR-0128 + * D1–D3). A ciphertext sealed before that keeps the older binding over + * `(namespace, key)` alone until it is re-wrapped. This command re-wraps it + * through `rotateKey`, the seam §4 names, under the scope of the producer + * whose holder references the row. `utils/sys-secret-rewrap.ts` holds the + * planning, the per-row steps and the reasons, and this file does the I/O. + * + * - **A dry run by default.** It boots read-only (the `os migrate plan` boot), + * opens, re-seals and verifies every attributed row in memory, prints + * classes and counts, and writes nothing. `--apply` writes. + * - **Never run for you.** Nothing on any boot or upgrade path invokes it, and + * it must not grow such a caller. + * - **Operator-only.** ⛔ No HTTP surface, so no refusal here answers a + * request (ADR-0112). + * - **Classes and counts only.** ⛔ It never prints a plaintext, a ciphertext, + * key material, or a row id beside its holder. + * + * ## The key + * + * Rows open only under the key that sealed them, so the provider is resolved + * from a key that ALREADY exists: `OS_SECRET_KEY`, `OS_DEV_CRYPTO_KEY`, or the + * persisted key file, the way every host resolves it. It is constructed in the + * strict posture and with the auto-key opt-in withheld, whatever `NODE_ENV` + * says, so this command never mints a key: a minted key can open nothing that + * is stored. No key is a refusal, before any row is opened. The provider is + * `LocalCryptoProvider`, the one every in-tree host constructs. + */ +export default class SecretRewrap extends Command { + static override description = + 'Re-wrap version-1 `sys_secret` ciphertext under the current AAD derivation, each row under the ' + + 'scope of the producer that holds it. A dry run by default: it writes nothing without --apply.'; + + static override examples = [ + '<%= config.bin %> secret rewrap --no-declared-datasources', + '<%= config.bin %> secret rewrap --json --no-declared-datasources', + '<%= config.bin %> secret rewrap --declared-datasources ./datasources.json', + '<%= config.bin %> secret rewrap --apply --no-declared-datasources', + ]; + + static override flags = { + 'database-url': Flags.string({ + description: 'Database URL to re-wrap (defaults to $OS_DATABASE_URL / the project DB)', + env: 'OS_DATABASE_URL', + }), + apply: Flags.boolean({ + description: + 'Write the re-wrapped rows (default is a dry run that writes nothing). Refuses whenever a ' + + 'holder family could not be enumerated.', + default: false, + }), + 'declared-datasources': Flags.string({ + description: + 'Path to a JSON file listing the datasource artefacts this host declares IN CODE (an array, ' + + 'or {"datasources": [...]}). Only the host can answer for a datasource nothing installed.', + exclusive: ['no-declared-datasources'], + }), + 'no-declared-datasources': Flags.boolean({ + description: + 'State that this host declares NO code-defined datasources. Saying nothing leaves the ' + + 'datasource family a gap, and --apply is refused.', + default: false, + exclusive: ['declared-datasources'], + }), + yes: Flags.boolean({ char: 'y', description: 'Skip the --apply confirmation prompt', default: false }), + json: Flags.boolean({ + description: 'Output as JSON (implies non-interactive; requires --yes to apply)', + default: false, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(SecretRewrap); + const timer = createTimer(); + const json = flags.json; + const mode: 'dry-run' | 'apply' = flags.apply ? 'apply' : 'dry-run'; + + if (!json) printHeader('Secret · re-wrap version-1 sys_secret ciphertext'); + + // The host's answer for the datasource family, read BEFORE the boot. A + // file that does not parse is nobody having answered, never `[]`. + let declaredDatasources: readonly DatasourceArtefactLike[] | undefined; + if (flags['no-declared-datasources']) { + declaredDatasources = []; + } else if (flags['declared-datasources']) { + try { + declaredDatasources = readDeclaredDatasources(flags['declared-datasources']); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (json) { await emitJson({ error: 'declared_datasources_unreadable', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + } + + if (!json) printStep(flags.apply ? 'Booting (APPLY mode)…' : 'Booting (dry run)…'); + + // Loaded at the point of use, never at module load: oclif imports every + // command module on every invocation while building its table. + const { collectSecretReferenceUnion } = await import('../../utils/secret-reference-union.js'); + const { + asCompareAndSetWriter, + buildRewrapReport, + executeSysSecretRewrap, + planSysSecretRewrap, + rewrapUnfinished, + } = await import('../../utils/sys-secret-rewrap.js'); + const { ciphertextDerivationStatus, LocalCryptoProvider } = await import('@objectstack/service-settings'); + const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); + + let stack; + try { + stack = await bootSchemaStack({ + jsonOutput: json, + databaseUrl: flags['database-url'], + // The platform objects register `sys_secret` and every holder object + // the union reads. Nothing else is composed: the settings service is + // not needed here, and it would construct a provider of its own. + extraPlugins: [new PlatformObjectsPlugin()], + // The dry run boots READ-ONLY, the boot `os migrate plan` takes. + // `--apply` keeps the plain boot: it writes rows. + ...(flags.apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (json) { await emitJson({ error: 'boot_failed', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + try { + const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined; + if (!engine) { + const message = 'No ObjectQL engine on this runtime, so no holder family can be enumerated.'; + if (json) { await emitJson({ error: 'no_engine', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + const secretDriver = engine.getDriverForObject('sys_secret'); + if (!secretDriver) { + const message = 'No driver resolves for `sys_secret`, so its rows could not be read.'; + if (json) { await emitJson({ error: 'no_sys_secret_driver', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + // Read at DRIVER level and UNSCOPED, as the union reads its holders: a + // row missing from this read is a row the run never mentions. + const secrets: RewrapSecretRow[] = (await secretDriver.find('sys_secret', {})).map((r) => ({ + id: String(r.id), + namespace: String(r.namespace ?? ''), + key: String(r.key ?? ''), + kms_key_id: r.kms_key_id, + alg: r.alg, + version: r.version, + ciphertext: r.ciphertext, + })); + + const union = await collectSecretReferenceUnion({ engine, declaredDatasources }); + const plan = planSysSecretRewrap({ secrets, union, derivationOf: ciphertextDerivationStatus }); + + // ── --apply: refusals that come before any row is opened ───────────── + // An incomplete union settles every version-1 row as left at planning + // time (`attempts` is 0), so this run opens nothing and only counts. + if (flags.apply && plan.refusal) { + const report = buildRewrapReport({ + mode, + plan, + result: await executeSysSecretRewrap({ + plan, + provider: null, + derivationOf: ciphertextDerivationStatus, + writer: null, + }), + keySource: null, + }); + if (json) { await emitJson({ error: 'union_incomplete', refused: plan.refusal, report }, 1, { compact: true }); return; } + renderReport(report); + printError(plan.refusal.message); + for (const gap of plan.refusal.gaps) printError(` family '${gap.family}': ${gap.reason}`); + this.exit(1); + return; + } + + const writer = flags.apply ? asCompareAndSetWriter(secretDriver) : null; + if (flags.apply && plan.attempts > 0 && !writer) { + const message = + 'Refusing to re-wrap: the driver serving `sys_secret` exposes no updateMany(), so a row cannot ' + + 'be written conditionally on the ciphertext this run read. Without that, a value a producer ' + + 'wrote during the run could be overwritten. No row was opened or written.'; + if (json) { await emitJson({ error: 'driver_cannot_compare_and_set', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + // ── The provider, from a key that already exists, or a refusal ─────── + let provider: (RewrapProviderLike & { keySource: string }) | null = null; + if (plan.attempts > 0) { + try { + provider = new LocalCryptoProvider({ + mode: 'production', + env: { ...process.env, OS_CRYPTO_AUTOKEY: undefined }, + }); + } catch (error) { + const message = + 'Refusing to re-wrap: no existing data key was found (OS_SECRET_KEY, OS_DEV_CRYPTO_KEY or the ' + + 'persisted key file), and a key minted now could open nothing that is stored. Run this with ' + + 'the key the deployment seals with. No row was opened or written. Cause: ' + + `${error instanceof Error ? error.message : String(error)}`; + if (json) { await emitJson({ error: 'crypto_key_unavailable', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + } + + if (flags.apply && plan.attempts > 0 && !flags.yes) { + if (json) { + await emitJson({ error: 'confirmation_required', hint: 'pass --yes' }, 1, { compact: true }); + return; + } + const ok = await confirm( + chalk.yellow(` Open, re-seal, verify and write up to ${plan.attempts} sys_secret row(s)? [y/N] `), + ); + if (!ok) { printInfo('Aborted — nothing was opened or written.'); return; } + } + + const result = await executeSysSecretRewrap({ + plan, + provider, + derivationOf: ciphertextDerivationStatus, + writer, + }); + const report = buildRewrapReport({ mode, plan, result, keySource: provider?.keySource ?? null }); + const exitCode = flags.apply && rewrapUnfinished(result) ? 1 : 0; + + if (json) { await emitJson({ mode, report }, exitCode, { compact: true }); return; } + renderReport(report); + if (flags.apply) { + printSuccess(`Re-wrapped ${report.counts.rewrap} sys_secret row(s) in ${timer.display()}.`); + } else { + printInfo(`Dry run — nothing was written. (${timer.display()})`); + } + if (exitCode !== 0) this.exit(exitCode); + } catch (error) { + // A read the run could not make is a refusal, and under `--json` a + // refusal is still one JSON document. The dry run boots read-only, so a + // database that lacks a table it reads is refused here. + if (isExitSignal(error)) throw error; + const message = error instanceof Error ? error.message : String(error); + if (json) { await emitJson({ error: 'scan_failed', message, ...errorCodeFields(error) }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + } finally { + await stack.shutdown(); + } + } +} + +async function confirm(question: string): Promise { + if (!process.stdin.isTTY) return false; // non-interactive → require --yes + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer: string = await new Promise((resolve) => rl.question(question, resolve)); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +/** Render the report for a human. Classes and counts only. */ +function renderReport(report: SysSecretRewrapReport): void { + console.log(chalk.bold('\n Reference union — one line per holder family')); + for (const [family, status] of Object.entries(report.families)) { + if (status.status === 'enumerated') { + printSuccess(`${family}: enumerated, ${status.referenceCount} reference(s)`); + } else { + printError(`${family}: GAP — ${status.reason}`); + } + } + + const c = report.byClass; + const s = report.rewrapByScope; + console.log(chalk.bold(`\n sys_secret rows (${report.mode})`)); + printInfo(`total ${report.counts.total}`); + printInfo( + `${report.mode === 'apply' ? 're-wrapped' : 'would re-wrap'} ${report.counts.rewrap} ` + + `(settings ${s.settings} · object_secret_field ${s.object_secret_field} · ` + + `datasource_credential ${s.datasource_credential})`, + ); + printInfo(`done (already current) ${report.counts.done}`); + printInfo( + `left ${report.counts.left} (orphan ${c.left_orphan} · conflicting scope ${c.left_conflicting_scope} · ` + + `union incomplete ${c.left_union_incomplete})`, + ); + printInfo( + `refused ${report.counts.refused} (unreadable ${c.refused_unreadable} · unknown derivation ` + + `${c.refused_unknown_derivation} · verify failed ${c.refused_verify_failed})`, + ); + if (report.mode === 'apply') { + printInfo( + `not written ${report.counts.notWritten} (changed during the run ${c.write_conflict} · write failed ` + + `${c.write_failed})`, + ); + } + if (report.keySource) printInfo(`data key source: ${report.keySource}`); + + console.log(chalk.bold('\n Read before acting')); + for (const note of report.notes) printWarning(note); +} diff --git a/packages/cli/src/utils/sys-secret-rewrap.ts b/packages/cli/src/utils/sys-secret-rewrap.ts new file mode 100644 index 00000000000..75d607ac5dc --- /dev/null +++ b/packages/cli/src/utils/sys-secret-rewrap.ts @@ -0,0 +1,540 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The at-rest re-wrap of version-1 `sys_secret` ciphertext — ADR-0128 §4.2. + * + * ADR-0128 D1–D3 made every new seal bind its producer's scope into a + * delimiter-safe, versioned AAD. A ciphertext sealed before that carries the + * older binding over `(namespace, key)` alone, and keeps it until it is + * re-wrapped. This module plans that re-wrap and runs it through the seam §4 + * names, `rotateKey`, which opens a row with the derivation the row records and + * seals it again under the current one. `os secret rewrap` does the I/O. + * + * §4.2 fixes three properties, and each one is a structure here, not a hope: + * + * - **Resumable.** All progress lives in the rows themselves: a row sealed + * under the current derivation reads `current` off its own marker and is + * skipped as done. There is no run log to lose. A run stopped part-way is + * re-run, re-reads the rows, and finishes the rest. Re-running a finished + * run writes nothing. + * - **Safe against a live deployment.** Each row is written by ONE conditional + * update keyed on the row's id AND the exact ciphertext this run read and + * re-sealed (`updateMany` with that `where`, which every driver serves as a + * single filtered statement and answers with the count it changed). A + * producer that changed or removed the row in between leaves the update + * matching nothing: the row is reported `write_conflict` and its new value + * is never overwritten. A re-run picks it up again. + * - **Fails closed.** A row that does not open is not written. Neither is a + * row whose re-seal does not open, under the same scope, to the same + * plaintext — verified BEFORE the write. Each such row is reported and the + * run carries on with the rest, then the command exits non-zero. A row is + * written in one statement or not at all. + * + * ## Whose scope a row is re-sealed under + * + * `rotateKey(handle, ctx)` seals under the CALLER's scope, and `sys_secret` + * records no producer. A version-1 ciphertext does not bind a scope, so + * opening it proves nothing about which producer sealed it. The scope + * therefore comes from the row's HOLDER, through the reachability + * classification the orphan sweep already makes: the cross-producer reference + * union (`secret-reference-union.ts`). Each of its references carries the + * holder's family, so no second holder walk exists here, only a grouping of + * the union's references by handle. {@link SCOPE_OF_HOLDER_FAMILY} maps each + * family to its producer's scope. + * + * ⛔ Never a guessed scope (ADR-0128 D3). A row is LEFT as it is, and reported + * by class, when: + * + * - no holder references it (an orphan); + * - its holders belong to different producers (a conflicting scope); + * - the union is incomplete. A family that could not be enumerated may hold + * the row too, so a single visible scope is not proof that there is only + * one. + * + * Sealing a row under a scope its producer does not open with would make it + * unreadable to that producer, and would put it in a vocabulary that is not + * its own. Several holders of ONE scope are one attribution, and the row is + * re-wrapped once. + * + * ## What leaves this module + * + * Classes and counts only. ⛔ No plaintext, no ciphertext, no key material, and + * no row id beside its holder's coordinates. The plaintext a row opens to + * exists for the length of that row's step, for the verify comparison, and is + * never returned. + */ + +import type { CryptoContext, CryptoContextScope, CryptoHandle } from '@objectstack/spec/contracts'; +import type { CiphertextDerivationStatus } from '@objectstack/service-settings'; +import { + SECRET_REFERENCE_FAMILIES, + type SecretReferenceFamily, + type SecretReferenceUnion, +} from './secret-reference-union.js'; + +/** + * The producer scope each holder family's references are sealed under. + * + * A `Record` over the closed family set, so a fourth family cannot join the + * union without a scope here: the map stops compiling first. The pairing is + * the producers' own (ADR-0128 D1): `SettingsService` holds in + * `sys_setting.value_enc` and seals under `settings`, the engine's + * secret-field path holds a `secret:` ref on the business row and seals under + * `object_secret_field`, and the datasource binder holds a `sys_secret:` + * `credentialsRef` and seals under `datasource_credential`. + */ +export const SCOPE_OF_HOLDER_FAMILY: Readonly> = + Object.freeze({ + settings: 'settings', + 'object-field': 'object_secret_field', + datasource: 'datasource_credential', + }); + +/** + * Where one `sys_secret` row ended up. Closed, and every row lands in exactly + * one class. + * + * - `rewrap` — attributed to one producer, opened, re-sealed and verified. + * Written under `--apply`, and only reported in a dry run. + * - `done` — already sealed under the current derivation. Skipped. + * - `left_orphan` · `left_conflicting_scope` · `left_union_incomplete` — + * no single producer can be attributed, so the row is left as it is. + * - `refused_unreadable` — the row does not open under its producer's + * context, or its stored fields do not form a handle. Not written. + * - `refused_unknown_derivation` — its marker names a derivation the provider + * does not know. Not written. + * - `refused_verify_failed` — the re-seal did not open to the same plaintext + * under the same scope. Not written. + * - `write_conflict` — (`--apply`) the row changed between this run's read + * and its write. Not overwritten. + * - `write_failed` — (`--apply`) the store refused the write, or answered + * something other than a count. + */ +export const REWRAP_CLASSES = [ + 'rewrap', + 'done', + 'left_orphan', + 'left_conflicting_scope', + 'left_union_incomplete', + 'refused_unreadable', + 'refused_unknown_derivation', + 'refused_verify_failed', + 'write_conflict', + 'write_failed', +] as const; + +export type RewrapClass = (typeof REWRAP_CLASSES)[number]; + +/** The classes a row is LEFT in: no single producer could be attributed. */ +export type RewrapLeftClass = 'left_orphan' | 'left_conflicting_scope' | 'left_union_incomplete'; + +/** + * The classes that make a run exit non-zero: a row the run could not finish. + * `left_*` is not among them. A left row is the answer, not a failure. + */ +export const REWRAP_UNFINISHED_CLASSES: readonly RewrapClass[] = [ + 'refused_unreadable', + 'refused_unknown_derivation', + 'refused_verify_failed', + 'write_conflict', + 'write_failed', +]; + +/** A `sys_secret` row as the driver returned it. Only the re-wrap reads it. */ +export interface RewrapSecretRow { + id: string; + namespace: string; + key: string; + kms_key_id?: unknown; + alg?: unknown; + version?: unknown; + ciphertext?: unknown; +} + +/** The provider slice the re-wrap uses: open, re-seal, and nothing else. */ +export interface RewrapProviderLike { + decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise; + rotateKey(handle: CryptoHandle, ctx: CryptoContext): Promise; +} + +/** + * The single WRITE the re-wrap needs: a conditional update that answers how + * many rows it changed. Declared apart from the union's read-only driver port + * so the two cannot be confused. `updateMany` is optional on `IDataDriver`, so + * {@link asCompareAndSetWriter} checks for it rather than casting. + */ +export interface RewrapWriterLike { + updateMany(object: string, query: Record, data: Record): Promise; +} + +/** The driver, if it can perform the conditional write. `null` is a refusal. */ +export function asCompareAndSetWriter(driver: unknown): RewrapWriterLike | null { + const candidate = driver as Partial | null | undefined; + return candidate && typeof candidate.updateMany === 'function' + ? (candidate as RewrapWriterLike) + : null; +} + +/** Per-family passthrough of the union's own outcome. ⛔ Never a boolean. */ +export interface RewrapFamilyStatus { + status: 'enumerated' | 'gap'; + /** Present only on a gap: the union's own words for why. */ + reason?: string; + referenceCount: number; +} + +/** The refusal an `--apply` run carries when the union is incomplete. */ +export interface RewrapRefusal { + gaps: ReadonlyArray<{ family: SecretReferenceFamily; reason: string }>; + message: string; +} + +/** One planned row. `scope` is set exactly when the row is to be attempted. */ +export interface RewrapPlanEntry { + row: RewrapSecretRow; + /** The attributed producer scope. Present iff the row is to be attempted. */ + scope?: CryptoContextScope; + /** The class already settled at planning time. Absent iff `scope` is set. */ + settled?: RewrapClass; +} + +/** The plan. Holds rows for the executor. ⛔ Never printed or serialised. */ +export interface SysSecretRewrapPlan { + entries: RewrapPlanEntry[]; + families: Record; + /** `null` when the union is complete. */ + refusal: RewrapRefusal | null; + /** Rows the executor will open. */ + attempts: number; +} + +/** + * Group the union's references by handle into the set of producer scopes + * holding each one. + * + * This is not a second holder walk. The union walked the holders once, and + * every reference it returns already names its holder's family. This only + * groups those references. + */ +export function holderScopesByHandle( + union: SecretReferenceUnion, +): ReadonlyMap> { + const byHandle = new Map>(); + for (const ref of union.references) { + const scope = SCOPE_OF_HOLDER_FAMILY[ref.family]; + const set = byHandle.get(ref.handleId); + if (set) set.add(scope); + else byHandle.set(ref.handleId, new Set([scope])); + } + return byHandle; +} + +/** + * The producer scope a row is re-sealed under, or the reason it is left. + * + * ⛔ Never a guess (ADR-0128 D3). The order matters: + * + * 1. Holders of more than one scope are a conflict however complete the + * union is. A missing family could add a holder, never remove one. + * 2. An incomplete union cannot prove a single visible scope is the only + * one, and it cannot tell an orphan from a row the missing family holds. + * 3. A row no holder references has no producer to attribute. + */ +export function attributeRewrapScope( + scopes: ReadonlySet | undefined, + unionComplete: boolean, +): { scope: CryptoContextScope } | { left: RewrapLeftClass } { + if (scopes && scopes.size > 1) return { left: 'left_conflicting_scope' }; + if (!unionComplete) return { left: 'left_union_incomplete' }; + if (!scopes || scopes.size === 0) return { left: 'left_orphan' }; + const [scope] = scopes; + return { scope }; +} + +/** + * Plan a re-wrap. Pure: nothing here opens, writes or reads a store. + * + * @param input.secrets every `sys_secret` row, read unscoped. + * @param input.union the cross-producer reference union. + * @param input.derivationOf the provider's own marker reading + * (`ciphertextDerivationStatus`), injected rather than restated. + */ +export function planSysSecretRewrap(input: { + secrets: readonly RewrapSecretRow[]; + union: SecretReferenceUnion; + derivationOf: (ciphertext: unknown) => CiphertextDerivationStatus; +}): SysSecretRewrapPlan { + const { union, derivationOf } = input; + const scopesOf = holderScopesByHandle(union); + const entries: RewrapPlanEntry[] = []; + let attempts = 0; + + for (const row of input.secrets ?? []) { + // The derivation first: a row sealed under the current one is done + // whoever holds it, and an unknown one is refused whoever holds it. + const derivation = derivationOf(row.ciphertext); + if (derivation === 'current') { entries.push({ row, settled: 'done' }); continue; } + if (derivation === 'unknown') { entries.push({ row, settled: 'refused_unknown_derivation' }); continue; } + + const attribution = attributeRewrapScope(scopesOf.get(row.id), union.complete); + if ('left' in attribution) { entries.push({ row, settled: attribution.left }); continue; } + entries.push({ row, scope: attribution.scope }); + attempts += 1; + } + + const families = {} as Record; + for (const family of SECRET_REFERENCE_FAMILIES) { + const result = union.families[family]; + families[family] = { + status: result.status, + ...(result.status === 'gap' ? { reason: result.reason } : {}), + referenceCount: result.references.length, + }; + } + + const refusal: RewrapRefusal | null = union.complete + ? null + : { + gaps: union.gaps, + message: + `Refusing to re-wrap: ${union.gaps.length} of ${SECRET_REFERENCE_FAMILIES.length} holder families ` + + `could not be enumerated (${union.gaps.map((g) => g.family).join(', ')}). A row's producer ` + + 'scope comes from its holders, and a family that was not read may hold the row too, so no ' + + 'row can be attributed until every family is. Close the gap and re-run.', + }; + + return { entries, families, refusal, attempts }; +} + +/** A stored `version` as a handle needs it, or `null` when it is not one. */ +function handleVersionOf(value: unknown): number | null { + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value; + if (typeof value === 'string' && /^[0-9]{1,15}$/.test(value)) return Number(value); + return null; +} + +/** The handle a stored row describes, or `null` when its fields cannot form one. */ +function handleOf(row: RewrapSecretRow): CryptoHandle | null { + const version = handleVersionOf(row.version); + if (typeof row.ciphertext !== 'string' || row.ciphertext === '' || version === null) return null; + return { + id: row.id, + kmsKeyId: typeof row.kms_key_id === 'string' ? row.kms_key_id : '', + alg: typeof row.alg === 'string' ? row.alg : '', + version, + ciphertext: row.ciphertext, + }; +} + +/** + * Does the re-seal hold the same value, for the same producer, at the same + * handle? Checked before anything is written. + * + * It must keep the id, record the current derivation, carry a usable version, + * and open under the SAME context to the SAME plaintext. A re-seal that fails + * any of these would replace a readable row with one its producer cannot use. + */ +async function resealHolds(input: { + provider: RewrapProviderLike; + derivationOf: (ciphertext: unknown) => CiphertextDerivationStatus; + row: RewrapSecretRow; + next: CryptoHandle; + ctx: CryptoContext; + plain: string; +}): Promise { + const { provider, derivationOf, row, next, ctx, plain } = input; + if (!next || next.id !== row.id) return false; + if (derivationOf(next.ciphertext) !== 'current') return false; + if (handleVersionOf(next.version) === null) return false; + try { + return (await provider.decrypt(next, ctx)) === plain; + } catch { + return false; + } +} + +/** The outcome of a run. Classes and counts only. */ +export interface SysSecretRewrapResult { + /** One count per member of {@link REWRAP_CLASSES}. */ + byClass: Record; + /** `rewrap` broken down by the producer scope each row was sealed under. */ + rewrapByScope: Record; + total: number; +} + +/** + * Run a plan: open each attempted row under its attributed scope, re-seal it + * through `rotateKey`, verify the re-seal, and, when a writer is given, write + * it with one conditional update. + * + * `writer: null` is the dry run. Every row is still opened, re-sealed and + * verified in memory, so `refused_*` reads the same as it would under + * `--apply`, and nothing is written. + */ +export async function executeSysSecretRewrap(input: { + plan: SysSecretRewrapPlan; + /** Required whenever `plan.attempts > 0`. */ + provider: RewrapProviderLike | null; + derivationOf: (ciphertext: unknown) => CiphertextDerivationStatus; + writer: RewrapWriterLike | null; + now?: () => Date; +}): Promise { + const { plan, provider, derivationOf, writer } = input; + const now = input.now ?? (() => new Date()); + const byClass = Object.fromEntries(REWRAP_CLASSES.map((c) => [c, 0])) as Record; + const rewrapByScope = { settings: 0, object_secret_field: 0, datasource_credential: 0 } as Record< + CryptoContextScope, + number + >; + if (plan.attempts > 0 && !provider) { + throw new Error('executeSysSecretRewrap: the plan attempts rows, and no provider was given to open them.'); + } + + for (const entry of plan.entries) { + if (entry.settled !== undefined || entry.scope === undefined) { + byClass[entry.settled ?? 'refused_unreadable'] += 1; + continue; + } + const outcome = await rewrapOne({ + row: entry.row, + scope: entry.scope, + provider: provider!, + derivationOf, + writer, + now, + }); + byClass[outcome] += 1; + if (outcome === 'rewrap') rewrapByScope[entry.scope] += 1; + } + + return { byClass, rewrapByScope, total: plan.entries.length }; +} + +/** One row, start to finish. Every exit is a class. Nothing escapes but the class. */ +async function rewrapOne(input: { + row: RewrapSecretRow; + scope: CryptoContextScope; + provider: RewrapProviderLike; + derivationOf: (ciphertext: unknown) => CiphertextDerivationStatus; + writer: RewrapWriterLike | null; + now: () => Date; +}): Promise { + const { row, scope, provider, derivationOf, writer, now } = input; + const handle = handleOf(row); + if (!handle) return 'refused_unreadable'; + // The row's own coordinate, the one every producer opens it with. + const ctx: CryptoContext = { scope, namespace: row.namespace, key: row.key }; + + let plain: string; + let next: CryptoHandle; + try { + plain = await provider.decrypt(handle, ctx); + next = await provider.rotateKey(handle, ctx); + } catch { + return 'refused_unreadable'; + } + + // Verify BEFORE the write: the re-seal must open, under the same scope, to + // the same plaintext. Otherwise the row stays exactly as it is. + if (!(await resealHolds({ provider, derivationOf, row, next, ctx, plain }))) { + return 'refused_verify_failed'; + } + + if (!writer) return 'rewrap'; + + // One statement, conditional on the ciphertext this run read and re-sealed. + // A row a producer changed or removed since matches nothing, and is never + // overwritten. + let changed: unknown; + try { + changed = await writer.updateMany( + 'sys_secret', + { where: { id: row.id, ciphertext: handle.ciphertext } }, + { + ciphertext: next.ciphertext, + version: next.version, + kms_key_id: next.kmsKeyId, + alg: next.alg, + rotated_at: now().toISOString(), + }, + ); + } catch { + return 'write_failed'; + } + if (changed === 0) return 'write_conflict'; + if (typeof changed !== 'number' || !Number.isFinite(changed) || changed < 0) return 'write_failed'; + return 'rewrap'; +} + +/** True when the result holds a row the run could not finish. */ +export function rewrapUnfinished(result: SysSecretRewrapResult): boolean { + return REWRAP_UNFINISHED_CLASSES.some((c) => result.byClass[c] > 0); +} + +/** The report a run prints or serialises. Classes and counts only. */ +export interface SysSecretRewrapReport { + mode: 'dry-run' | 'apply'; + /** Where the data key came from (a source name, never a value), or `null` when no row was opened. */ + keySource: string | null; + families: Record; + refusal: RewrapRefusal | null; + counts: { + total: number; + rewrap: number; + done: number; + left: number; + refused: number; + notWritten: number; + }; + byClass: Record; + rewrapByScope: Record; + notes: string[]; +} + +/** Operator notes, kept as data so the wording is pinned rather than left to a rendering site. */ +export const REWRAP_NOTES = { + left: + 'A row is LEFT as it is when no holder references it, when its holders belong to different ' + + 'producers, or when a holder family could not be read. Its producer cannot be attributed, and ' + + 'it is never re-sealed under a guessed scope. It keeps the older binding over (namespace, key) alone.', + resumable: + 'Re-running is safe. A row already sealed under the current derivation is skipped as done, so a ' + + 'run that stopped part-way finishes the rest, and a finished run writes nothing.', + dryRun: + 'Dry run: every attributed row was opened, re-sealed and verified in memory, and nothing was ' + + 'written. Re-run with --apply to write.', + unfinished: + 'Some rows were not re-wrapped (see the refused and not-written counts). Each was left exactly as ' + + 'it was. A row that changed during the run is picked up by a re-run.', +} as const; + +/** Assemble the report. */ +export function buildRewrapReport(input: { + mode: 'dry-run' | 'apply'; + plan: SysSecretRewrapPlan; + result: SysSecretRewrapResult; + keySource: string | null; +}): SysSecretRewrapReport { + const { mode, plan, result, keySource } = input; + const c = result.byClass; + const notes: string[] = [REWRAP_NOTES.left, REWRAP_NOTES.resumable]; + if (mode === 'dry-run') notes.push(REWRAP_NOTES.dryRun); + if (rewrapUnfinished(result)) notes.push(REWRAP_NOTES.unfinished); + return { + mode, + keySource, + families: plan.families, + refusal: plan.refusal, + counts: { + total: result.total, + rewrap: c.rewrap, + done: c.done, + left: c.left_orphan + c.left_conflicting_scope + c.left_union_incomplete, + refused: c.refused_unreadable + c.refused_unknown_derivation + c.refused_verify_failed, + notWritten: c.write_conflict + c.write_failed, + }, + byClass: { ...c }, + rewrapByScope: { ...result.rewrapByScope }, + notes, + }; +} From 9d9fd6d5770de8d8404aedb6fbd239fd38660bea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:29:28 +0000 Subject: [PATCH 3/9] =?UTF-8?q?test(cli):=20pin=20the=20re-wrap's=20attrib?= =?UTF-8?q?ution,=20resumability,=20live-safety,=20fail-closed=20and=20ver?= =?UTF-8?q?ify-before-write=20(ADR-0128=20=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../cli/src/utils/sys-secret-rewrap.test.ts | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 packages/cli/src/utils/sys-secret-rewrap.test.ts diff --git a/packages/cli/src/utils/sys-secret-rewrap.test.ts b/packages/cli/src/utils/sys-secret-rewrap.test.ts new file mode 100644 index 00000000000..f22139555dc --- /dev/null +++ b/packages/cli/src/utils/sys-secret-rewrap.test.ts @@ -0,0 +1,630 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0128 §4.2 — pins for the at-rest re-wrap planner and executor. + * + * Everything that can run against real code does: a real `ObjectQL`, the real + * `LocalCryptoProvider`, the real reference union, the real datasource + * credential binder, and the provider's own `ciphertextDerivationStatus`. The + * store is a minimal driver double whose `updateMany` is the same single + * conditional statement the real drivers serve; the real SQL driver's answer + * to that statement is pinned through the command's own boot in + * `commands/secret/rewrap.driver-contract.test.ts`. + * + * The version-1 rows are sealed here the way every release before ADR-0128 + * sealed them, because the provider no longer seals version 1. The provider + * then opening them IS the check that this file's version-1 sealing matches + * the provider's version-1 reading. + * + * After `--apply`, every re-wrapped row is opened through ITS PRODUCER'S OWN + * READ PATH: the engine's `resolveSecret` for an object secret field, the + * binder's `resolve` for a datasource credential, and the settings service's + * context for a setting. A row re-sealed under the wrong producer's scope + * would fail exactly there. That is the strongest form of "the scope came + * from the holder" this file can assert. + * + * Every "nothing happened" assertion has a positive control that makes the + * same thing happen to the same row once the guard's condition is lifted. + */ + +import { describe, it, expect } from 'vitest'; +import { createCipheriv, randomBytes } from 'node:crypto'; +import { ObjectQL } from '@objectstack/objectql'; +import { createDatasourceSecretBinder } from '@objectstack/service-datasource'; +import { ciphertextDerivationStatus, LocalCryptoProvider } from '@objectstack/service-settings'; +import { + CRYPTO_CONTEXT_SCOPES, + type CryptoContext, + type CryptoContextScope, + type CryptoHandle, +} from '@objectstack/spec/contracts'; +import { + collectSecretReferenceUnion, + SECRET_REFERENCE_FAMILIES, + type SecretReferenceEngineLike, +} from './secret-reference-union.js'; +import { + asCompareAndSetWriter, + attributeRewrapScope, + buildRewrapReport, + executeSysSecretRewrap, + planSysSecretRewrap, + REWRAP_CLASSES, + rewrapUnfinished, + SCOPE_OF_HOLDER_FAMILY, + type RewrapProviderLike, + type RewrapSecretRow, + type RewrapWriterLike, +} from './sys-secret-rewrap.js'; + +type Row = Record; + +/** The deployment's data key. */ +const KEY = Buffer.from('202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f', 'hex'); + +/** + * A version-1 ciphertext: AES-256-GCM, AAD the UTF-8 of `namespace|key`, bare + * base64 of iv || tag || cipher. The shape every handle sealed before ADR-0128 + * has at rest. + */ +function sealVersion1(plain: string, namespace: string, key: string, dataKey: Buffer = KEY): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', dataKey, iv); + cipher.setAAD(Buffer.from([namespace, key].join('|'), 'utf8')); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64'); +} + +/** + * A driver double. `updateMany` filters on EVERY `where` key by equality and + * answers the count it changed, in one synchronous step: the conditional + * statement the real drivers serve. + */ +function makeDriver() { + const stores = new Map>(); + const storeFor = (object: string) => { + let s = stores.get(object); + if (!s) { s = new Map(); stores.set(object, s); } + return s; + }; + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Row)) { + if (k.startsWith('$')) continue; + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const copy = (r: Row): Row => ({ ...r }); + const writes: Array<{ object: string; where: Row; data: Row }> = []; + + const driver = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast?: Row) { + const matched = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched; + return page.map(copy); + }, + async create(object: string, data: Row) { + const row = { ...data, id: String(data.id) }; + storeFor(object).set(String(row.id), row); + return copy(row); + }, + async updateMany(object: string, query: Row, data: Row) { + writes.push({ object, where: { ...(query?.where as Row) }, data: { ...data } }); + let changed = 0; + for (const [id, row] of storeFor(object)) { + if (!matches(row, query?.where)) continue; + storeFor(object).set(id, { ...row, ...data }); + changed += 1; + } + return changed; + }, + async count(object: string, ast?: Row) { + return (await this.find(object, ast)).length; + }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + + return { + driver, + seed(object: string, row: Row) { storeFor(object).set(String(row.id), { ...row }); }, + get(object: string, id: string): Row | undefined { + const row = storeFor(object).get(id); + return row ? copy(row) : undefined; + }, + rowsOf(object: string) { + return Array.from(storeFor(object).values()).map(copy).sort((a, b) => String(a.id).localeCompare(String(b.id))); + }, + writes, + }; +} + +const TEST_PACKAGE_ID = 'com.objectstack.test.rewrap'; +const textField = (name: string) => ({ name, label: name, type: 'text' as const }); +const objectOf = (name: string, fields: string[], extra: Row = {}) => ({ + name, + label: name, + fields: { ...Object.fromEntries(fields.map((f) => [f, textField(f)])), ...extra }, +}); + +const sysSecretObject = objectOf('sys_secret', ['id', 'namespace', 'key', 'kms_key_id', 'alg', 'ciphertext', 'created_at', 'rotated_at'], { + version: { name: 'version', label: 'version', type: 'number' as const }, +}); +const sysSettingObject = objectOf('sys_setting', ['id', 'namespace', 'key', 'scope', 'user_id', 'value', 'value_enc']); +const sysMetadataObject = objectOf('sys_metadata', ['id', 'name', 'type', 'scope', 'metadata', 'state']); +/** A business object with a `secret` field: family 2's holder. */ +const vaultObject = objectOf('vault_entry', ['id', 'label'], { + token: { name: 'token', label: 'token', type: 'secret' as const }, +}); + +/** The plaintext each row holds. Never expected in any report. */ +const PLAIN = { + settings: 'smtp-app-password-41', + objectField: 'vault-token-42', + datasource: 'pg-password-43', + orphan: 'retired-value-44', + conflict: 'shared-value-45', + multi: 'twice-held-46', + current: 'already-current-47', + unknown: 'unknown-derivation-48', + unreadable: 'other-key-49', +} as const; + +const ID = { + settings: 'sec_rw_settings', + objectField: 'sec_rw_object_field', + datasource: 'sec_rw_datasource', + orphan: 'sec_rw_orphan', + conflict: 'sec_rw_conflict', + multi: 'sec_rw_multi', + current: 'sec_rw_current', + unknown: 'sec_rw_unknown', + unreadable: 'sec_rw_unreadable', +} as const; + +interface BuildOptions { + /** Leave the orphan with no holder (default) or give it a settings holder. */ + orphanHeld?: boolean; + /** Give the conflicting row its second, other-producer holder (default true). */ + conflictSecondHolder?: boolean; +} + +async function buildRuntime(opts: BuildOptions = {}) { + const store = makeDriver(); + const engine = new ObjectQL(); + engine.registerDriver(store.driver as never, true); + await engine.init(); + for (const object of [sysSecretObject, sysSettingObject, sysMetadataObject, vaultObject]) { + engine.registry.registerObject(object as never, TEST_PACKAGE_ID); + } + const provider = new LocalCryptoProvider({ key: KEY }); + engine.setCryptoProvider(provider as never); + + const seedV1 = (id: string, namespace: string, key: string, plain: string, dataKey?: Buffer) => + store.seed('sys_secret', { + id, namespace, key, kms_key_id: 'local:v1', alg: 'aes-256-gcm', version: 1, + ciphertext: sealVersion1(plain, namespace, key, dataKey), created_at: '2026-01-01T00:00:00.000Z', + }); + const setting = (id: string, namespace: string, key: string, handleId: string, extra: Row = {}) => + store.seed('sys_setting', { id, namespace, key, scope: 'tenant', user_id: null, value_enc: handleId, ...extra }); + const datasource = (id: string, name: string, handleId: string) => + store.seed('sys_metadata', { + id, name, type: 'datasource', scope: 'platform', state: 'active', + metadata: JSON.stringify({ name, driver: 'postgres', external: { credentialsRef: `sys_secret:${handleId}` } }), + }); + + // Family 1 — a setting. + seedV1(ID.settings, 'smtp', 'password', PLAIN.settings); + setting('set_1', 'smtp', 'password', ID.settings); + + // Family 2 — a `secret:` ref on a business row, under the engine's coordinate. + seedV1(ID.objectField, 'vault_entry', 'token', PLAIN.objectField); + store.seed('vault_entry', { id: 'rec_1', label: 'primary', token: `secret:${ID.objectField}` }); + + // Family 3 — a datasource credentialsRef, under the binder's coordinate. + seedV1(ID.datasource, 'datasource', 'reporting', PLAIN.datasource); + datasource('meta_1', 'reporting', ID.datasource); + + // No holder: an orphan (unless a test gives it one, as its positive control). + seedV1(ID.orphan, 'smtp', 'retired_token', PLAIN.orphan); + if (opts.orphanHeld) setting('set_orphan_control', 'smtp', 'retired_token', ID.orphan); + + // Two holders of different producers. + seedV1(ID.conflict, 'smtp', 'shared_secret', PLAIN.conflict); + setting('set_conflict', 'smtp', 'shared_secret', ID.conflict); + if (opts.conflictSecondHolder !== false) datasource('meta_conflict', 'shared', ID.conflict); + + // Two holders of ONE producer: one attribution. + seedV1(ID.multi, 'mail', 'api_key', PLAIN.multi); + setting('set_multi_tenant', 'mail', 'api_key', ID.multi); + setting('set_multi_user', 'mail', 'api_key', ID.multi, { scope: 'user', user_id: 'usr_1' }); + + // Already sealed under the current derivation. + const current = await provider.encrypt(PLAIN.current, { scope: 'settings', namespace: 'mail', key: 'host_token' }); + store.seed('sys_secret', { + id: ID.current, namespace: 'mail', key: 'host_token', kms_key_id: current.kmsKeyId, alg: current.alg, + version: current.version, ciphertext: current.ciphertext, + }); + setting('set_current', 'mail', 'host_token', ID.current); + + // A derivation the provider does not know. + store.seed('sys_secret', { + id: ID.unknown, namespace: 'mail', key: 'future_token', kms_key_id: 'local:v1', alg: 'aes-256-gcm', + version: 1, ciphertext: 'v9:' + sealVersion1(PLAIN.unknown, 'mail', 'future_token'), + }); + setting('set_unknown', 'mail', 'future_token', ID.unknown); + + // Version 1, but sealed under a key this deployment does not hold. + seedV1(ID.unreadable, 'mail', 'lost_token', PLAIN.unreadable, randomBytes(32)); + setting('set_unreadable', 'mail', 'lost_token', ID.unreadable); + + const binder = createDatasourceSecretBinder({ engine: engine as never, cryptoProvider: provider as never }); + return { store, engine, provider, binder }; +} + +type Runtime = Awaited>; + +const secretRowsOf = (rt: Runtime): RewrapSecretRow[] => + rt.store.rowsOf('sys_secret').map((r) => ({ + id: String(r.id), namespace: String(r.namespace), key: String(r.key), + kms_key_id: r.kms_key_id, alg: r.alg, version: r.version, ciphertext: r.ciphertext, + })); + +/** Plan from the store as it stands now, as the command does on every run. */ +async function planNow(rt: Runtime, declaredDatasources: readonly Row[] | undefined = []) { + const union = await collectSecretReferenceUnion({ + engine: rt.engine as unknown as SecretReferenceEngineLike, + declaredDatasources, + }); + return planSysSecretRewrap({ secrets: secretRowsOf(rt), union, derivationOf: ciphertextDerivationStatus }); +} + +async function runNow( + rt: Runtime, + opts: { apply: boolean; provider?: RewrapProviderLike; writer?: RewrapWriterLike | null; declared?: readonly Row[] | undefined } = { apply: true }, +) { + const plan = await planNow(rt, 'declared' in opts ? opts.declared : []); + const writer = opts.apply + ? (opts.writer !== undefined ? opts.writer : asCompareAndSetWriter(rt.store.driver)) + : null; + const result = await executeSysSecretRewrap({ + plan, + provider: opts.provider ?? rt.provider, + derivationOf: ciphertextDerivationStatus, + writer, + }); + return { plan, result }; +} + +/** A provider wrapper that records which handles were opened or re-sealed. */ +function recordingProvider(inner: RewrapProviderLike) { + const rotated: string[] = []; + const opened: string[] = []; + const provider: RewrapProviderLike = { + async decrypt(handle, ctx) { opened.push(handle.id); return inner.decrypt(handle, ctx); }, + async rotateKey(handle, ctx) { rotated.push(handle.id); return inner.rotateKey(handle, ctx); }, + }; + return { provider, rotated, opened }; +} + +const settingsCtx = (namespace: string, key: string): CryptoContext => ({ scope: 'settings', namespace, key }); + +describe('ADR-0128 §4.2 — the scope comes from the holder', () => { + it('maps each holder family to its own producer scope, one to one, onto the closed scope set', () => { + expect(SCOPE_OF_HOLDER_FAMILY).toEqual({ + settings: 'settings', + 'object-field': 'object_secret_field', + datasource: 'datasource_credential', + }); + expect(Object.keys(SCOPE_OF_HOLDER_FAMILY).sort()).toEqual([...SECRET_REFERENCE_FAMILIES].sort()); + expect(Object.values(SCOPE_OF_HOLDER_FAMILY).sort()).toEqual([...CRYPTO_CONTEXT_SCOPES].sort()); + }); + + it('re-wraps every attributable version-1 row, and each producer then opens its own row through its own read path', async () => { + const rt = await buildRuntime(); + const { plan, result } = await runNow(rt, { apply: true }); + + expect(plan.refusal).toBeNull(); + expect(result.byClass).toMatchObject({ + rewrap: 4, done: 1, left_orphan: 1, left_conflicting_scope: 1, left_union_incomplete: 0, + refused_unreadable: 1, refused_unknown_derivation: 1, refused_verify_failed: 0, + write_conflict: 0, write_failed: 0, + }); + expect(result.rewrapByScope).toEqual({ settings: 2, object_secret_field: 1, datasource_credential: 1 }); + expect(result.total).toBe(9); + + for (const id of [ID.settings, ID.objectField, ID.datasource, ID.multi]) { + const row = rt.store.get('sys_secret', id)!; + expect(ciphertextDerivationStatus(row.ciphertext), id).toBe('current'); + expect(row.version, id).toBe(2); + expect(typeof row.rotated_at, id).toBe('string'); + } + + // The engine's secret-field read path, under its own scope. + expect(await rt.engine.resolveSecret(`secret:${ID.objectField}`)).toBe(PLAIN.objectField); + // The datasource binder's read path, under its own scope. + expect(await rt.binder.resolve(`sys_secret:${ID.datasource}`)).toBe(PLAIN.datasource); + // The settings service's context: the setting row's own coordinate, scope `settings`. + const opened = (id: string, ctx: CryptoContext) => { + const r = rt.store.get('sys_secret', id)!; + const handle: CryptoHandle = { + id, kmsKeyId: String(r.kms_key_id), alg: String(r.alg), version: Number(r.version), ciphertext: String(r.ciphertext), + }; + return rt.provider.decrypt(handle, ctx); + }; + expect(await opened(ID.settings, settingsCtx('smtp', 'password'))).toBe(PLAIN.settings); + expect(await opened(ID.multi, settingsCtx('mail', 'api_key'))).toBe(PLAIN.multi); + + // And now bound: no OTHER producer's context opens any of them. + const sealedAs: Array<[string, CryptoContext]> = [ + [ID.settings, settingsCtx('smtp', 'password')], + [ID.objectField, { scope: 'object_secret_field', namespace: 'vault_entry', key: 'token' }], + [ID.datasource, { scope: 'datasource_credential', namespace: 'datasource', key: 'reporting' }], + ]; + for (const [id, ctx] of sealedAs) { + for (const scope of CRYPTO_CONTEXT_SCOPES) { + if (scope === ctx.scope) continue; + await expect(opened(id, { ...ctx, scope }), `${id} under ${scope}`).rejects.toThrow(); + } + } + }); + + it('an ORPHAN is left exactly as it is — never re-sealed under a guessed scope', async () => { + const rt = await buildRuntime(); + const before = rt.store.get('sys_secret', ID.orphan); + const rec = recordingProvider(rt.provider); + + const { result } = await runNow(rt, { apply: true, provider: rec.provider }); + + expect(result.byClass.left_orphan).toBe(1); + expect(rt.store.get('sys_secret', ID.orphan)).toEqual(before); + expect(rec.rotated).not.toContain(ID.orphan); + expect(rec.opened).not.toContain(ID.orphan); + expect(rt.store.writes.map((w) => w.where.id)).not.toContain(ID.orphan); + + // POSITIVE CONTROL: the same row, once a holder references it, IS re-wrapped. + const held = await buildRuntime({ orphanHeld: true }); + const control = await runNow(held, { apply: true }); + expect(control.result.byClass.left_orphan).toBe(0); + expect(ciphertextDerivationStatus(held.store.get('sys_secret', ID.orphan)!.ciphertext)).toBe('current'); + }); + + it('holders of DIFFERENT producers leave the row as it is', async () => { + const rt = await buildRuntime(); + const before = rt.store.get('sys_secret', ID.conflict); + const rec = recordingProvider(rt.provider); + + const { result } = await runNow(rt, { apply: true, provider: rec.provider }); + + expect(result.byClass.left_conflicting_scope).toBe(1); + expect(rt.store.get('sys_secret', ID.conflict)).toEqual(before); + expect(rec.rotated).not.toContain(ID.conflict); + + // POSITIVE CONTROL: with only its settings holder, the same row is re-wrapped. + const single = await buildRuntime({ conflictSecondHolder: false }); + await runNow(single, { apply: true }); + expect(ciphertextDerivationStatus(single.store.get('sys_secret', ID.conflict)!.ciphertext)).toBe('current'); + }); + + it('several holders of ONE producer are one attribution: the row is re-wrapped once', async () => { + const rt = await buildRuntime(); + await runNow(rt, { apply: true }); + expect(rt.store.writes.filter((w) => w.where.id === ID.multi)).toHaveLength(1); + expect(rt.store.get('sys_secret', ID.multi)!.version).toBe(2); + }); + + it('an INCOMPLETE union attributes nothing: every version-1 row is left, and nothing is opened or written', async () => { + const rt = await buildRuntime(); + const before = rt.store.rowsOf('sys_secret'); + const rec = recordingProvider(rt.provider); + + // The host did not answer for its code-declared datasources: a declared gap. + const { plan, result } = await runNow(rt, { apply: true, provider: rec.provider, declared: undefined }); + + expect(plan.refusal?.gaps.map((g) => g.family)).toEqual(['datasource']); + expect(plan.attempts).toBe(0); + // The conflict is still a conflict (a missing family adds holders, never removes one). + expect(result.byClass).toMatchObject({ + rewrap: 0, left_union_incomplete: 6, left_conflicting_scope: 1, done: 1, refused_unknown_derivation: 1, + }); + expect(rec.opened).toEqual([]); + expect(rt.store.writes).toEqual([]); + expect(rt.store.rowsOf('sys_secret')).toEqual(before); + + // The single-row decision, read directly. + const one = new Set(['settings']); + expect(attributeRewrapScope(one, false)).toEqual({ left: 'left_union_incomplete' }); + expect(attributeRewrapScope(one, true)).toEqual({ scope: 'settings' }); + }); +}); + +describe('ADR-0128 §4.2 — resumable, live-safe, fail-closed', () => { + it('RESUMABLE: a run stopped part-way is re-run and finishes the rest; a finished run writes nothing', async () => { + const rt = await buildRuntime(); + const real = asCompareAndSetWriter(rt.store.driver)!; + let calls = 0; + let stoppedAt = ''; + // The run dies at its second write: that row, and only that row, is not written. + const dying: RewrapWriterLike = { + async updateMany(object, query, data) { + calls += 1; + if (calls === 2) { + stoppedAt = String((query.where as Row).id); + throw new Error('the process was stopped here'); + } + return real.updateMany(object, query, data); + }, + }; + const first = await runNow(rt, { apply: true, writer: dying }); + expect(first.result.byClass.rewrap).toBe(3); + expect(first.result.byClass.write_failed).toBe(1); + expect(rewrapUnfinished(first.result)).toBe(true); + expect([ID.settings, ID.objectField, ID.datasource, ID.multi]).toContain(stoppedAt); + expect(ciphertextDerivationStatus(rt.store.get('sys_secret', stoppedAt)!.ciphertext)).toBe('superseded'); + + // Re-run: the three already done are skipped, the one left behind is finished. + const second = await runNow(rt, { apply: true }); + expect(second.result.byClass.rewrap).toBe(1); + expect(second.result.byClass.done).toBe(1 + 3); + expect(ciphertextDerivationStatus(rt.store.get('sys_secret', stoppedAt)!.ciphertext)).toBe('current'); + + // Idempotent: a third run re-wraps nothing and writes nothing. + const writesBefore = rt.store.writes.length; + const third = await runNow(rt, { apply: true }); + expect(third.result.byClass.rewrap).toBe(0); + expect(third.result.byClass.done).toBe(5); + expect(rt.store.writes.length).toBe(writesBefore); + }); + + it('LIVE-SAFE: a row a producer changed between the read and the write is not overwritten', async () => { + const rt = await buildRuntime(); + const real = asCompareAndSetWriter(rt.store.driver)!; + const concurrent = 'v2:written-by-a-producer-during-the-run'; + // A producer writes the row while this run holds its re-seal, just before the write lands. + const racing: RewrapWriterLike = { + async updateMany(object, query, data) { + const where = query.where as Row; + if (where.id === ID.settings) { + const row = rt.store.get('sys_secret', ID.settings)!; + rt.store.seed('sys_secret', { ...row, ciphertext: concurrent, version: 7 }); + } + return real.updateMany(object, query, data); + }, + }; + + const { result } = await runNow(rt, { apply: true, writer: racing }); + + expect(result.byClass.write_conflict).toBe(1); + expect(rt.store.get('sys_secret', ID.settings)).toMatchObject({ ciphertext: concurrent, version: 7 }); + expect(rewrapUnfinished(result)).toBe(true); + // POSITIVE CONTROL: the other rows in the same run were written. + expect(result.byClass.rewrap).toBe(3); + // The write was conditional on the exact ciphertext this run read. + const write = rt.store.writes.find((w) => w.where.id === ID.settings)!; + expect(Object.keys(write.where).sort()).toEqual(['ciphertext', 'id']); + expect(ciphertextDerivationStatus(write.where.ciphertext)).toBe('superseded'); + }); + + it('FAILS CLOSED: a row that does not open is not written, and the run finishes the rest', async () => { + const rt = await buildRuntime(); + const before = rt.store.get('sys_secret', ID.unreadable); + + const { result } = await runNow(rt, { apply: true }); + + expect(result.byClass.refused_unreadable).toBe(1); + expect(rt.store.get('sys_secret', ID.unreadable)).toEqual(before); + expect(rt.store.writes.map((w) => w.where.id)).not.toContain(ID.unreadable); + expect(rewrapUnfinished(result)).toBe(true); + // The rest of the run still happened. + expect(result.byClass.rewrap).toBe(4); + }); + + it('FAILS CLOSED: an unknown derivation is refused, not opened and not written', async () => { + const rt = await buildRuntime(); + const before = rt.store.get('sys_secret', ID.unknown); + const rec = recordingProvider(rt.provider); + + const { result } = await runNow(rt, { apply: true, provider: rec.provider }); + + expect(result.byClass.refused_unknown_derivation).toBe(1); + expect(rec.opened).not.toContain(ID.unknown); + expect(rt.store.get('sys_secret', ID.unknown)).toEqual(before); + }); + + it('VERIFY BEFORE WRITE: a re-seal that does not open to the same plaintext is never written', async () => { + const rt = await buildRuntime(); + const before = rt.store.get('sys_secret', ID.settings); + // A re-seal that is well-formed, current, keeps the id and opens under + // the same scope — but to a DIFFERENT value. + const drifting: RewrapProviderLike = { + decrypt: (h, c) => rt.provider.decrypt(h, c), + async rotateKey(handle, ctx) { + if (handle.id !== ID.settings) return rt.provider.rotateKey(handle, ctx); + const next = await rt.provider.encrypt('not-the-stored-value', ctx); + return { ...next, id: handle.id, version: handle.version + 1 }; + }, + }; + + const { result } = await runNow(rt, { apply: true, provider: drifting }); + + expect(result.byClass.refused_verify_failed).toBe(1); + expect(rt.store.get('sys_secret', ID.settings)).toEqual(before); + expect(rt.store.writes.map((w) => w.where.id)).not.toContain(ID.settings); + expect(rewrapUnfinished(result)).toBe(true); + + // A re-seal that does not open at all is refused the same way. + const rt2 = await buildRuntime(); + const before2 = rt2.store.get('sys_secret', ID.settings); + const broken: RewrapProviderLike = { + decrypt: (h, c) => rt2.provider.decrypt(h, c), + async rotateKey(handle, ctx) { + const next = await rt2.provider.rotateKey(handle, ctx); + return handle.id === ID.settings ? { ...next, ciphertext: next.ciphertext.slice(0, -6) + 'AAAAAA' } : next; + }, + }; + const second = await runNow(rt2, { apply: true, provider: broken }); + expect(second.result.byClass.refused_verify_failed).toBe(1); + expect(rt2.store.get('sys_secret', ID.settings)).toEqual(before2); + + // POSITIVE CONTROL: the honest provider re-wraps the same row. + const rt3 = await buildRuntime(); + await runNow(rt3, { apply: true }); + expect(ciphertextDerivationStatus(rt3.store.get('sys_secret', ID.settings)!.ciphertext)).toBe('current'); + }); + + it('DRY RUN: the same classes as --apply, and nothing is written', async () => { + const rt = await buildRuntime(); + const before = rt.store.rowsOf('sys_secret'); + + const dry = await runNow(rt, { apply: false }); + + expect(rt.store.writes).toEqual([]); + expect(rt.store.rowsOf('sys_secret')).toEqual(before); + expect(rewrapUnfinished(dry.result)).toBe(true); // the refused rows read the same as under --apply + + // POSITIVE CONTROL: --apply over the same store lands exactly what the dry run counted. + const applied = await runNow(rt, { apply: true }); + expect(applied.result.byClass).toEqual(dry.result.byClass); + expect(rt.store.writes).toHaveLength(dry.result.byClass.rewrap); + }); + + it('refuses a driver that cannot write conditionally', () => { + expect(asCompareAndSetWriter({ async find() { return []; } })).toBeNull(); + expect(asCompareAndSetWriter({ updateMany: 'yes' })).toBeNull(); + expect(asCompareAndSetWriter(undefined)).toBeNull(); + const able = { async updateMany() { return 1; } }; + expect(asCompareAndSetWriter(able)).toBe(able); + }); +}); + +describe('ADR-0128 §4.2 — what leaves the run', () => { + it('the report carries classes and counts only: no plaintext, no ciphertext, no row id, no holder coordinate', async () => { + const rt = await buildRuntime(); + const rowsBefore = rt.store.rowsOf('sys_secret'); + const { plan, result } = await runNow(rt, { apply: true }); + const report = buildRewrapReport({ mode: 'apply', plan, result, keySource: rt.provider.keySource }); + const text = JSON.stringify(report); + + for (const plain of Object.values(PLAIN)) expect(text).not.toContain(plain); + for (const id of Object.values(ID)) expect(text).not.toContain(id); + for (const row of [...rowsBefore, ...rt.store.rowsOf('sys_secret')]) { + expect(text).not.toContain(String(row.ciphertext)); + } + for (const holder of ['set_1', 'rec_1', 'meta_1', 'vault_entry', 'reporting', 'smtp']) { + expect(text).not.toContain(holder); + } + // Not vacuous: the report does carry the counts. + expect(report.counts).toEqual({ total: 9, rewrap: 4, done: 1, left: 2, refused: 2, notWritten: 0 }); + expect(Object.keys(report.byClass)).toEqual([...REWRAP_CLASSES]); + expect(report.keySource).toBe('explicit'); + }); +}); From 2ddee01a3ad7457929c6bf750df1905454d9b214 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:30:45 +0000 Subject: [PATCH 4/9] test(cli): pass an unanswered datasource declaration through as undefined in the re-wrap pins Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- packages/cli/src/utils/sys-secret-rewrap.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/sys-secret-rewrap.test.ts b/packages/cli/src/utils/sys-secret-rewrap.test.ts index f22139555dc..79c11e0184a 100644 --- a/packages/cli/src/utils/sys-secret-rewrap.test.ts +++ b/packages/cli/src/utils/sys-secret-rewrap.test.ts @@ -279,8 +279,12 @@ const secretRowsOf = (rt: Runtime): RewrapSecretRow[] => kms_key_id: r.kms_key_id, alg: r.alg, version: r.version, ciphertext: r.ciphertext, })); -/** Plan from the store as it stands now, as the command does on every run. */ -async function planNow(rt: Runtime, declaredDatasources: readonly Row[] | undefined = []) { +/** + * Plan from the store as it stands now, as the command does on every run. + * `undefined` is the host NOT answering for its code-declared datasources, so + * it is passed through as it is, never defaulted to `[]`. + */ +async function planNow(rt: Runtime, declaredDatasources: readonly Row[] | undefined) { const union = await collectSecretReferenceUnion({ engine: rt.engine as unknown as SecretReferenceEngineLike, declaredDatasources, From 202fb29c5f51f6b66584fb4b0a137fb7c86f93fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:32:19 +0000 Subject: [PATCH 5/9] =?UTF-8?q?test(cli):=20os=20secret=20rewrap=20guards?= =?UTF-8?q?=20and=20its=20concrete-driver=20contract=20(ADR-0128=20=C2=A74?= =?UTF-8?q?.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../secret/rewrap.driver-contract.test.ts | 240 +++++++++++++++++ .../src/commands/secret/rewrap.guards.test.ts | 247 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 packages/cli/src/commands/secret/rewrap.driver-contract.test.ts create mode 100644 packages/cli/src/commands/secret/rewrap.guards.test.ts diff --git a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts new file mode 100644 index 00000000000..38a0212bb71 --- /dev/null +++ b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts @@ -0,0 +1,240 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0128 §4.2 — `os secret rewrap` against the CONCRETE driver its own boot + * resolves, end to end. + * + * The planner's pins (`utils/sys-secret-rewrap.test.ts`) run over a driver + * double. Two facts are only true or false of the real store, so they are read + * here, through `bootSchemaStack` with the command's own plugin list, against + * a real SQLite file: + * + * - **The conditional write really is conditional.** `updateMany` keyed on + * `id` AND the ciphertext the run read answers 0, and changes nothing, + * when the stored ciphertext is any other value. It answers 1 when it is + * the same. Live-safety rests on exactly this, and a driver that ignored + * one `where` key would overwrite a producer's concurrent value. + * - **The command end to end.** The dry run writes nothing (the whole table + * is read back unchanged). `--apply` re-wraps exactly the attributable + * version-1 rows, under their holders' scopes, and leaves the orphan + * byte-for-byte as it was. A second `--apply` is all `done` and writes + * nothing. + * + * The version-1 rows are sealed here as every release before ADR-0128 sealed + * them, under the key this file hands the run in `OS_SECRET_KEY`. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createCipheriv, randomBytes } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { ciphertextDerivationStatus, LocalCryptoProvider } from '@objectstack/service-settings'; +import type { CryptoContext } from '@objectstack/spec/contracts'; +import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js'; +import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js'; +import SecretRewrap from './rewrap.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); + +/** Env that would point the boot at another database or another state directory. */ +const OVERRIDING_ENV = ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME'] as const; + +type Row = Record; + +interface DriverProbe { + find(object: string, query: Row): Promise; + create(object: string, data: Row): Promise; + updateMany(object: string, query: Row, data: Row): Promise; +} + +const KEY = randomBytes(32); + +function sealVersion1(plain: string, namespace: string, key: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', KEY, iv); + cipher.setAAD(Buffer.from([namespace, key].join('|'), 'utf8')); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64'); +} + +const v1Row = (id: string, namespace: string, key: string, plain: string): Row => ({ + id, namespace, key, alg: 'aes-256-gcm', version: 1, kms_key_id: 'local:v1', + ciphertext: sealVersion1(plain, namespace, key), +}); + +/** Held by a setting. */ +const SETTINGS_ROW = v1Row('sec_rewrap_dc_settings', 'smtp', 'password', 'dc-settings-plain'); +/** Held by a datasource the host declares in code. */ +const DATASOURCE_ROW = v1Row('sec_rewrap_dc_datasource', 'datasource', 'warehouse', 'dc-datasource-plain'); +/** Held by nothing. */ +const ORPHAN_ROW = v1Row('sec_rewrap_dc_orphan', 'smtp', 'retired_token', 'dc-orphan-plain'); +/** A row only the conditional-write probe touches. */ +const PROBE_ROW = v1Row('sec_rewrap_dc_probe', 'probe', 'probe', 'dc-probe-plain'); + +describe('os secret rewrap — the concrete driver and the command, end to end (ADR-0128 §4.2)', () => { + let dir: string; + let dbFile: string; + let declaredFile: string; + let stack: SchemaStack | null = null; + let secretDriver: DriverProbe; + const savedEnv: Record = {}; + const savedCwd = process.cwd(); + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-rewrap-dc-')); + dbFile = join(dir, 'rewrap.db'); + declaredFile = join(dir, 'datasources.json'); + + for (const key of OVERRIDING_ENV) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + for (const key of ['OS_ARTIFACT_PATH', 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key]; + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + process.env.NODE_ENV = 'production'; + process.env.OS_SECRET_KEY = KEY.toString('hex'); + process.chdir(dir); + + stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${dbFile}`, + // Byte-identical to `rewrap.ts`'s own list. + extraPlugins: [new PlatformObjectsPlugin()], + }); + const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined; + if (!engine) throw new Error('no objectql engine on the booted stack — nothing to measure'); + secretDriver = engine.getDriverForObject('sys_secret') as unknown as DriverProbe; + const settingDriver = engine.getDriverForObject('sys_setting') as unknown as DriverProbe; + if (!secretDriver || !settingDriver) throw new Error('sys_secret / sys_setting resolved no driver'); + + for (const row of [SETTINGS_ROW, DATASOURCE_ROW, ORPHAN_ROW, PROBE_ROW]) { + await secretDriver.create('sys_secret', { ...row }); + } + await settingDriver.create('sys_setting', { namespace: 'smtp', key: 'password', value_enc: SETTINGS_ROW.id }); + writeFileSync(declaredFile, JSON.stringify([ + { name: 'warehouse', external: { credentialsRef: `sys_secret:${String(DATASOURCE_ROW.id)}` } }, + ])); + }, 180_000); + + afterAll(async () => { + try { await stack?.shutdown(); } catch { /* torn down either way */ } + stack = null; + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + const rowOf = async (id: unknown): Promise => { + const [row] = await secretDriver.find('sys_secret', { where: { id } }); + if (!row) throw new Error(`sys_secret row ${String(id)} is gone`); + return row; + }; + + const runJson = async (argv: string[]): Promise<{ payload: Record; exitCode: number }> => { + const chunks: string[] = []; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation( + ((chunk: unknown, ...rest: unknown[]) => { + chunks.push(String(chunk)); + const done = rest.find((a) => typeof a === 'function') as ((e?: Error | null) => void) | undefined; + done?.(null); + return true; + }) as never, + ); + const savedExitCode = process.exitCode; + let exitCode = 0; + try { + await SecretRewrap.run(['--json', '--database-url', `file:${dbFile}`, ...argv], { root: CLI_ROOT }); + exitCode = Number(process.exitCode ?? 0); + } finally { + stdout.mockRestore(); + process.exitCode = savedExitCode; + } + const lines = chunks.join('').split('\n').filter((l) => l.trim() !== ''); + return { payload: JSON.parse(lines[lines.length - 1]) as Record, exitCode }; + }; + + it('names the concrete driver behind the write', () => { + expect((secretDriver as unknown as { name?: unknown }).name).toBe('com.objectstack.driver.sql'); + expect(typeof secretDriver.updateMany).toBe('function'); + }); + + it('the conditional write is conditional: a stale ciphertext changes nothing, the read one changes the row', async () => { + const before = await rowOf(PROBE_ROW.id); + + const stale = await secretDriver.updateMany( + 'sys_secret', + { where: { id: PROBE_ROW.id, ciphertext: 'v2:a-value-this-run-never-read' } }, + { ciphertext: 'v2:must-not-land', version: 99 }, + ); + expect(stale).toBe(0); + const unchanged = await rowOf(PROBE_ROW.id); + expect(unchanged.ciphertext).toBe(before.ciphertext); + expect(Number(unchanged.version)).toBe(Number(before.version)); + + // POSITIVE CONTROL: the same statement keyed on the ciphertext actually stored. + const matched = await secretDriver.updateMany( + 'sys_secret', + { where: { id: PROBE_ROW.id, ciphertext: before.ciphertext } }, + { kms_key_id: 'local:v1' }, + ); + expect(matched).toBe(1); + }, 60_000); + + it('the dry run writes nothing, and counts what --apply would do', async () => { + const before = await secretDriver.find('sys_secret', {}); + const { payload, exitCode } = await runJson(['--declared-datasources', declaredFile]); + + expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBeUndefined(); + expect(payload.mode).toBe('dry-run'); + expect(payload.report.families.settings.status).toBe('enumerated'); + expect(payload.report.families.datasource.status).toBe('enumerated'); + expect(payload.report.byClass).toMatchObject({ rewrap: 2, left_orphan: 2, done: 0 }); + expect(payload.report.rewrapByScope).toEqual({ settings: 1, object_secret_field: 0, datasource_credential: 1 }); + expect(exitCode).toBe(0); + expect(await secretDriver.find('sys_secret', {})).toEqual(before); + }, 180_000); + + it('--apply re-wraps each held row under its holder\'s scope, leaves the orphan as it was, and a re-run is all done', async () => { + const orphanBefore = await rowOf(ORPHAN_ROW.id); + const { payload, exitCode } = await runJson(['--apply', '--yes', '--declared-datasources', declaredFile]); + + expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBeUndefined(); + expect(payload.report.byClass).toMatchObject({ rewrap: 2, left_orphan: 2, write_conflict: 0, write_failed: 0 }); + expect(exitCode).toBe(0); + + const provider = new LocalCryptoProvider({ key: KEY }); + const opens = async (row: Row, ctx: CryptoContext) => { + const stored = await rowOf(row.id); + expect(ciphertextDerivationStatus(stored.ciphertext)).toBe('current'); + return provider.decrypt({ + id: String(stored.id), kmsKeyId: String(stored.kms_key_id), alg: String(stored.alg), + version: Number(stored.version), ciphertext: String(stored.ciphertext), + }, ctx); + }; + expect(await opens(SETTINGS_ROW, { scope: 'settings', namespace: 'smtp', key: 'password' })) + .toBe('dc-settings-plain'); + expect(await opens(DATASOURCE_ROW, { scope: 'datasource_credential', namespace: 'datasource', key: 'warehouse' })) + .toBe('dc-datasource-plain'); + // Bound to its holder's scope: another producer's context does not open it. + await expect(opens(DATASOURCE_ROW, { scope: 'settings', namespace: 'datasource', key: 'warehouse' })).rejects.toThrow(); + + // The orphan is exactly as it was. + const orphanAfter = await rowOf(ORPHAN_ROW.id); + expect(orphanAfter.ciphertext).toBe(orphanBefore.ciphertext); + expect(Number(orphanAfter.version)).toBe(Number(orphanBefore.version)); + + // Re-run: everything held is done, nothing is written. + const tableBefore = await secretDriver.find('sys_secret', {}); + const again = await runJson(['--apply', '--yes', '--declared-datasources', declaredFile]); + expect(again.payload.report.byClass).toMatchObject({ rewrap: 0, done: 2, left_orphan: 2 }); + expect(again.exitCode).toBe(0); + expect(await secretDriver.find('sys_secret', {})).toEqual(tableBefore); + }, 180_000); +}); diff --git a/packages/cli/src/commands/secret/rewrap.guards.test.ts b/packages/cli/src/commands/secret/rewrap.guards.test.ts new file mode 100644 index 00000000000..d534d3cd6bd --- /dev/null +++ b/packages/cli/src/commands/secret/rewrap.guards.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0128 §4.2 — the command-level guards of `os secret rewrap`, tested away + * from the boot they normally sit behind. + * + * Each guard stops the run BEFORE a row is opened or written, and each one + * stands in front of a property the planner alone cannot hold: + * + * - an incomplete union under `--apply` is refused, naming the family, rather + * than re-wrapping rows whose producer was attributed from half the + * holders; + * - a driver with no conditional write is refused rather than written + * unconditionally, which could overwrite a value a producer wrote during + * the run; + * - no existing data key is refused, and ⛔ no key is minted: a minted key can + * open nothing that is stored, and a key file left behind would be picked + * up by the next boot of this host; + * - `--json --apply` without `--yes` is refused. + * + * Only the seams that would boot a database are replaced. The reference union, + * the planner, the executor, `ciphertextDerivationStatus` and + * `LocalCryptoProvider` all run for real. + */ + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { createCipheriv, randomBytes } from 'node:crypto'; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ciphertextDerivationStatus } from '@objectstack/service-settings'; +import SecretRewrap from './rewrap.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; + +vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() })); +// Constructed and handed to the (mocked) boot, never used. +vi.mock('@objectstack/platform-objects/plugin', () => ({ PlatformObjectsPlugin: class {} })); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); + +type Row = Record; + +const KEY_HEX = '404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f'; + +function sealVersion1(plain: string, namespace: string, key: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', Buffer.from(KEY_HEX, 'hex'), iv); + cipher.setAAD(Buffer.from([namespace, key].join('|'), 'utf8')); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64'); +} + +const SECRET_ID = 'sec_guard_settings'; +const PLAIN = 'guarded-plaintext-value'; + +/** One version-1 row its settings holder references. */ +function freshRows(): { secrets: Row[]; settings: Row[] } { + return { + secrets: [{ + id: SECRET_ID, namespace: 'smtp', key: 'password', kms_key_id: 'local:v1', alg: 'aes-256-gcm', + version: 1, ciphertext: sealVersion1(PLAIN, 'smtp', 'password'), + }], + settings: [{ id: 'set_1', namespace: 'smtp', key: 'password', scope: 'tenant', user_id: null, value_enc: SECRET_ID }], + }; +} + +interface Harness { + secrets: Row[]; + writes: Array<{ where: Row; data: Row }>; +} + +/** Wire the mocked boot to a fake engine over `rows`. */ +function wireBoot(rows: { secrets: Row[]; settings: Row[] }, opts: { conditionalWrite?: boolean } = {}): Harness { + const harness: Harness = { secrets: rows.secrets, writes: [] }; + const secretDriver: Record = { + async find() { return harness.secrets.map((r) => ({ ...r })); }, + }; + if (opts.conditionalWrite !== false) { + secretDriver.updateMany = async (_object: string, query: { where: Row }, data: Row) => { + harness.writes.push({ where: query.where, data }); + let changed = 0; + harness.secrets = harness.secrets.map((r) => { + if (r.id !== query.where.id || r.ciphertext !== query.where.ciphertext) return r; + changed += 1; + return { ...r, ...data }; + }); + return changed; + }; + } + const engine = { + getConfigs: () => ({}), + listDatasourceDefs: () => [], + getDriverForObject: (object: string) => { + if (object === 'sys_secret') return secretDriver; + if (object === 'sys_setting') return { async find() { return rows.settings.map((r) => ({ ...r })); } }; + if (object === 'sys_metadata') return { async find() { return []; } }; + return undefined; + }, + }; + vi.mocked(bootSchemaStack).mockResolvedValue({ + kernel: { getService: (name: string) => (name === 'objectql' ? engine : undefined) }, + shutdown: async () => {}, + } as never); + return harness; +} + +async function run(argv: string[]): Promise<{ payload: Record; exitCode: number }> { + const chunks: string[] = []; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation( + ((chunk: unknown, ...rest: unknown[]) => { + chunks.push(String(chunk)); + const done = rest.find((a) => typeof a === 'function') as ((e?: Error | null) => void) | undefined; + done?.(null); + return true; + }) as never, + ); + const savedExitCode = process.exitCode; + let exitCode = 0; + try { + await SecretRewrap.run(['--json', ...argv], { root: CLI_ROOT }); + exitCode = Number(process.exitCode ?? 0); + } finally { + stdout.mockRestore(); + process.exitCode = savedExitCode; + } + const lines = chunks.join('').split('\n').filter((l) => l.trim() !== ''); + return { payload: JSON.parse(lines[lines.length - 1]) as Record, exitCode }; +} + +const KEY_ENV = ['OS_SECRET_KEY', 'OS_DEV_CRYPTO_KEY', 'OBJECTSTACK_DEV_CRYPTO_KEY', 'OS_HOME', 'OBJECTSTACK_HOME', 'OS_CRYPTO_AUTOKEY'] as const; +const savedEnv: Record = {}; +let home: string; + +beforeEach(() => { + for (const k of KEY_ENV) savedEnv[k] = process.env[k]; + for (const k of KEY_ENV) delete process.env[k]; + home = mkdtempSync(join(tmpdir(), 'os-rewrap-guards-')); + // An empty key home: no persisted key file exists unless something mints one. + process.env.OS_HOME = home; + process.env.OS_SECRET_KEY = KEY_HEX; +}); + +afterEach(() => { + for (const k of KEY_ENV) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + rmSync(home, { recursive: true, force: true }); + vi.mocked(bootSchemaStack).mockReset(); +}); + +describe('os secret rewrap — guards that stop a run before any row is opened or written', () => { + it('--apply over an INCOMPLETE union is refused, naming the family, and nothing is written', async () => { + const h = wireBoot(freshRows()); + // No --no-declared-datasources: the host has not answered for its datasources. + const { payload, exitCode } = await run(['--apply', '--yes']); + + expect(payload.error).toBe('union_incomplete'); + expect(payload.refused.gaps.map((g: { family: string }) => g.family)).toEqual(['datasource']); + expect(payload.report.byClass.left_union_incomplete).toBe(1); + expect(exitCode).toBe(1); + expect(h.writes).toEqual([]); + + // POSITIVE CONTROL: the host answers, and the same run writes the row. + const answered = wireBoot(freshRows()); + const ok = await run(['--apply', '--yes', '--no-declared-datasources']); + expect(ok.payload.error).toBeUndefined(); + expect(ok.payload.report.counts.rewrap).toBe(1); + expect(ok.exitCode).toBe(0); + expect(answered.writes).toHaveLength(1); + expect(ciphertextDerivationStatus(answered.secrets[0].ciphertext)).toBe('current'); + }, 60_000); + + it('a driver with no conditional write is refused before anything is opened or written', async () => { + const h = wireBoot(freshRows(), { conditionalWrite: false }); + const before = JSON.stringify(h.secrets); + const { payload, exitCode } = await run(['--apply', '--yes', '--no-declared-datasources']); + + expect(payload.error).toBe('driver_cannot_compare_and_set'); + expect(exitCode).toBe(1); + expect(JSON.stringify(h.secrets)).toBe(before); + }, 60_000); + + it('no existing data key is refused, and no key is minted', async () => { + delete process.env.OS_SECRET_KEY; + const h = wireBoot(freshRows()); + const { payload, exitCode } = await run(['--apply', '--yes', '--no-declared-datasources']); + + expect(payload.error).toBe('crypto_key_unavailable'); + expect(exitCode).toBe(1); + expect(h.writes).toEqual([]); + // The strict posture never writes a key file, whatever NODE_ENV says. + expect(readdirSync(home)).toEqual([]); + expect(existsSync(join(home, 'dev-crypto-key'))).toBe(false); + + // Even with the auto-key opt-in present in the environment. + process.env.OS_CRYPTO_AUTOKEY = '1'; + const again = await run(['--no-declared-datasources']); + expect(again.payload.error).toBe('crypto_key_unavailable'); + expect(readdirSync(home)).toEqual([]); + }, 60_000); + + it('--json --apply without --yes is refused, and nothing is written', async () => { + const h = wireBoot(freshRows()); + const { payload, exitCode } = await run(['--apply', '--no-declared-datasources']); + + expect(payload.error).toBe('confirmation_required'); + expect(exitCode).toBe(1); + expect(h.writes).toEqual([]); + }, 60_000); + + it('the dry run is the default: it writes nothing and prints classes and counts only', async () => { + const h = wireBoot(freshRows()); + const before = JSON.stringify(h.secrets); + const { payload, exitCode } = await run(['--no-declared-datasources']); + + expect(payload.mode).toBe('dry-run'); + expect(payload.report.counts).toEqual({ total: 1, rewrap: 1, done: 0, left: 0, refused: 0, notWritten: 0 }); + expect(payload.report.rewrapByScope).toEqual({ settings: 1, object_secret_field: 0, datasource_credential: 0 }); + expect(payload.report.keySource).toBe('env:OS_SECRET_KEY'); + expect(exitCode).toBe(0); + expect(h.writes).toEqual([]); + expect(JSON.stringify(h.secrets)).toBe(before); + + const text = JSON.stringify(payload); + expect(text).not.toContain(PLAIN); + expect(text).not.toContain(SECRET_ID); + expect(text).not.toContain(String(h.secrets[0].ciphertext)); + expect(text).not.toContain(KEY_HEX); + }, 60_000); + + it('an unreadable --declared-datasources file is refused before the boot, never read as []', async () => { + const dir = mkdtempSync(join(tmpdir(), 'os-rewrap-ds-')); + try { + const file = join(dir, 'ds.json'); + writeFileSync(file, '{ not json'); + const { payload, exitCode } = await run(['--declared-datasources', file]); + expect(payload.error).toBe('declared_datasources_unreadable'); + expect(exitCode).toBe(1); + expect(vi.mocked(bootSchemaStack)).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); +}); From 5015d0db67af53937f1207d8c0a9967b594587c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:34:28 +0000 Subject: [PATCH 6/9] fix(cli): os secret rewrap boots the orphans composition and resolves its key before the boot The settings service registers sys_setting, the settings family's holder, so the re-wrap boots it as os secret orphans does. That service's own provider may mint a key in a development posture, so the re-wrap resolves its provider first, in the strict posture that never mints. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../secret/rewrap.driver-contract.test.ts | 4 +- packages/cli/src/commands/secret/rewrap.ts | 71 ++++++++++++------- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts index 38a0212bb71..2f239aaab44 100644 --- a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts +++ b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts @@ -31,7 +31,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; -import { ciphertextDerivationStatus, LocalCryptoProvider } from '@objectstack/service-settings'; +import { ciphertextDerivationStatus, LocalCryptoProvider, SettingsServicePlugin } from '@objectstack/service-settings'; import type { CryptoContext } from '@objectstack/spec/contracts'; import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js'; import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js'; @@ -103,7 +103,7 @@ describe('os secret rewrap — the concrete driver and the command, end to end ( jsonOutput: false, databaseUrl: `file:${dbFile}`, // Byte-identical to `rewrap.ts`'s own list. - extraPlugins: [new PlatformObjectsPlugin()], + extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })], }); const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined; if (!engine) throw new Error('no objectql engine on the booted stack — nothing to measure'); diff --git a/packages/cli/src/commands/secret/rewrap.ts b/packages/cli/src/commands/secret/rewrap.ts index 6f3d765d766..66cca291043 100644 --- a/packages/cli/src/commands/secret/rewrap.ts +++ b/packages/cli/src/commands/secret/rewrap.ts @@ -55,8 +55,9 @@ import { readDeclaredDatasources } from './orphans.js'; * persisted key file, the way every host resolves it. It is constructed in the * strict posture and with the auto-key opt-in withheld, whatever `NODE_ENV` * says, so this command never mints a key: a minted key can open nothing that - * is stored. No key is a refusal, before any row is opened. The provider is - * `LocalCryptoProvider`, the one every in-tree host constructs. + * is stored. It is resolved before the boot, so the key state it sees is the + * one the operator left. No key is a refusal, before any row is opened. The + * provider is `LocalCryptoProvider`, the one every in-tree host constructs. */ export default class SecretRewrap extends Command { static override description = @@ -138,18 +139,37 @@ export default class SecretRewrap extends Command { planSysSecretRewrap, rewrapUnfinished, } = await import('../../utils/sys-secret-rewrap.js'); - const { ciphertextDerivationStatus, LocalCryptoProvider } = await import('@objectstack/service-settings'); + const { ciphertextDerivationStatus, LocalCryptoProvider, SettingsServicePlugin } = + await import('@objectstack/service-settings'); const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); + // ── The provider, resolved BEFORE the boot, from a key that already exists ── + // Before, because the boot composes the settings service, and in a + // development posture that service's own provider may mint a key file when + // none exists. Resolved first, this run sees the key state as the operator + // left it. The strict posture never mints, and the auto-key opt-in is + // withheld. A missing key is refused only once the plan has a row to open, + // so a run with nothing to open still reports. + let provider: (RewrapProviderLike & { keySource: string }) | null = null; + let keyUnavailable: string | null = null; + try { + provider = new LocalCryptoProvider({ + mode: 'production', + env: { ...process.env, OS_CRYPTO_AUTOKEY: undefined }, + }); + } catch (error) { + keyUnavailable = error instanceof Error ? error.message : String(error); + } + let stack; try { stack = await bootSchemaStack({ jsonOutput: json, databaseUrl: flags['database-url'], - // The platform objects register `sys_secret` and every holder object - // the union reads. Nothing else is composed: the settings service is - // not needed here, and it would construct a provider of its own. - extraPlugins: [new PlatformObjectsPlugin()], + // The same composition `os secret orphans` boots: the platform objects + // register `sys_secret` and the holder objects, and the settings + // service registers `sys_setting`, the settings family's holder. + extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })], // The dry run boots READ-ONLY, the boot `os migrate plan` takes. // `--apply` keeps the plain boot: it writes rows. ...(flags.apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), @@ -231,25 +251,17 @@ export default class SecretRewrap extends Command { return; } - // ── The provider, from a key that already exists, or a refusal ─────── - let provider: (RewrapProviderLike & { keySource: string }) | null = null; - if (plan.attempts > 0) { - try { - provider = new LocalCryptoProvider({ - mode: 'production', - env: { ...process.env, OS_CRYPTO_AUTOKEY: undefined }, - }); - } catch (error) { - const message = - 'Refusing to re-wrap: no existing data key was found (OS_SECRET_KEY, OS_DEV_CRYPTO_KEY or the ' - + 'persisted key file), and a key minted now could open nothing that is stored. Run this with ' - + 'the key the deployment seals with. No row was opened or written. Cause: ' - + `${error instanceof Error ? error.message : String(error)}`; - if (json) { await emitJson({ error: 'crypto_key_unavailable', message }, 1, { compact: true }); return; } - printError(message); - this.exit(1); - return; - } + // ── A row to open needs a key that already existed before this run ─── + if (plan.attempts > 0 && !provider) { + const message = + 'Refusing to re-wrap: no existing data key was found (OS_SECRET_KEY, OS_DEV_CRYPTO_KEY or the ' + + 'persisted key file), and a key minted now could open nothing that is stored. Run this with ' + + 'the key the deployment seals with. No row was opened or written. Cause: ' + + `${keyUnavailable ?? 'no provider'}`; + if (json) { await emitJson({ error: 'crypto_key_unavailable', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; } if (flags.apply && plan.attempts > 0 && !flags.yes) { @@ -269,7 +281,12 @@ export default class SecretRewrap extends Command { derivationOf: ciphertextDerivationStatus, writer, }); - const report = buildRewrapReport({ mode, plan, result, keySource: provider?.keySource ?? null }); + const report = buildRewrapReport({ + mode, + plan, + result, + keySource: plan.attempts > 0 ? provider?.keySource ?? null : null, + }); const exitCode = flags.apply && rewrapUnfinished(result) ? 1 : 0; if (json) { await emitJson({ mode, report }, exitCode, { compact: true }); return; } From 3200804adbe45951d52097e549cda8e3cffd2aff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:37:32 +0000 Subject: [PATCH 7/9] feat(cli): os secret rewrap hands the composed settings service its own provider, so no provider in the run mints a key; join the bootSchemaStack families Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../secret/rewrap.driver-contract.test.ts | 38 +++++++++++++- packages/cli/src/commands/secret/rewrap.ts | 52 ++++++++++++++----- ...igrate.one-shot-family.integration.test.ts | 12 ++++- .../cli/test/json-stdout-purity.e2e.test.ts | 4 ++ 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts index 2f239aaab44..2129e641c11 100644 --- a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts +++ b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts @@ -26,7 +26,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { createCipheriv, randomBytes } from 'node:crypto'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -201,6 +201,42 @@ describe('os secret rewrap — the concrete driver and the command, end to end ( expect(await secretDriver.find('sys_secret', {})).toEqual(before); }, 180_000); + it('with no data key, the run refuses before opening a row, and NO provider in its boot mints one', async () => { + // A development posture with no key anywhere: the posture in which a + // default provider mints a key file. The settings service the boot + // composes is handed this run's provider, so nothing may mint here. + const home = mkdtempSync(join(tmpdir(), 'os-rewrap-nokey-')); + const saved = { NODE_ENV: process.env.NODE_ENV, OS_SECRET_KEY: process.env.OS_SECRET_KEY, OS_HOME: process.env.OS_HOME }; + process.env.NODE_ENV = 'development'; + delete process.env.OS_SECRET_KEY; + process.env.OS_HOME = home; + try { + const before = await secretDriver.find('sys_secret', {}); + const { payload, exitCode } = await runJson(['--declared-datasources', declaredFile]); + + expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBe('crypto_key_unavailable'); + expect(exitCode).toBe(1); + expect(existsSync(join(home, 'dev-crypto-key'))).toBe(false); + expect(await secretDriver.find('sys_secret', {})).toEqual(before); + + // POSITIVE CONTROL for the measurement: a default-posture provider in the + // same kind of home does mint, so an absent file above means none did. + const control = mkdtempSync(join(tmpdir(), 'os-rewrap-mint-')); + try { + new LocalCryptoProvider({ env: { OS_HOME: control }, mode: 'development' }); + expect(existsSync(join(control, 'dev-crypto-key'))).toBe(true); + } finally { + rmSync(control, { recursive: true, force: true }); + } + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(home, { recursive: true, force: true }); + } + }, 180_000); + it('--apply re-wraps each held row under its holder\'s scope, leaves the orphan as it was, and a re-run is all done', async () => { const orphanBefore = await rowOf(ORPHAN_ROW.id); const { payload, exitCode } = await runJson(['--apply', '--yes', '--declared-datasources', declaredFile]); diff --git a/packages/cli/src/commands/secret/rewrap.ts b/packages/cli/src/commands/secret/rewrap.ts index 66cca291043..a6372dbc814 100644 --- a/packages/cli/src/commands/secret/rewrap.ts +++ b/packages/cli/src/commands/secret/rewrap.ts @@ -20,8 +20,8 @@ import type { DatasourceArtefactLike, SecretReferenceEngineLike, } from '../../utils/secret-reference-union.js'; +import type { ICryptoProvider } from '@objectstack/spec/contracts'; import type { - RewrapProviderLike, RewrapSecretRow, SysSecretRewrapReport, } from '../../utils/sys-secret-rewrap.js'; @@ -55,9 +55,10 @@ import { readDeclaredDatasources } from './orphans.js'; * persisted key file, the way every host resolves it. It is constructed in the * strict posture and with the auto-key opt-in withheld, whatever `NODE_ENV` * says, so this command never mints a key: a minted key can open nothing that - * is stored. It is resolved before the boot, so the key state it sees is the - * one the operator left. No key is a refusal, before any row is opened. The - * provider is `LocalCryptoProvider`, the one every in-tree host constructs. + * is stored. It is resolved before the boot and handed to the settings + * service the boot composes, so no provider in this run mints a key. No key is + * a refusal, before any row is opened. The provider is `LocalCryptoProvider`, + * the one every in-tree host constructs. */ export default class SecretRewrap extends Command { static override description = @@ -144,13 +145,10 @@ export default class SecretRewrap extends Command { const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); // ── The provider, resolved BEFORE the boot, from a key that already exists ── - // Before, because the boot composes the settings service, and in a - // development posture that service's own provider may mint a key file when - // none exists. Resolved first, this run sees the key state as the operator - // left it. The strict posture never mints, and the auto-key opt-in is + // The strict posture never mints a key, and the auto-key opt-in is // withheld. A missing key is refused only once the plan has a row to open, // so a run with nothing to open still reports. - let provider: (RewrapProviderLike & { keySource: string }) | null = null; + let provider: (ICryptoProvider & { keySource: string }) | null = null; let keyUnavailable: string | null = null; try { provider = new LocalCryptoProvider({ @@ -166,10 +164,22 @@ export default class SecretRewrap extends Command { stack = await bootSchemaStack({ jsonOutput: json, databaseUrl: flags['database-url'], - // The same composition `os secret orphans` boots: the platform objects + // The composition `os secret orphans` boots: the platform objects // register `sys_secret` and the holder objects, and the settings - // service registers `sys_setting`, the settings family's holder. - extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })], + // service registers `sys_setting`, the settings family's holder. The + // settings service is handed THIS run's provider, so it does not + // construct one of its own: in a development posture with no key, its + // default would mint a key file, and the next run would then resolve a + // key under which nothing stored opens. With no key, it is handed one + // that refuses every call. Nothing in this one-shot boot reads a + // setting's value. + extraPlugins: [ + new PlatformObjectsPlugin(), + new SettingsServicePlugin({ + registerRoutes: false, + cryptoProvider: provider ?? refusingCryptoProvider(keyUnavailable ?? 'no data key'), + }), + ], // The dry run boots READ-ONLY, the boot `os migrate plan` takes. // `--apply` keeps the plain boot: it writes rows. ...(flags.apply ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), @@ -312,6 +322,24 @@ export default class SecretRewrap extends Command { } } +/** + * The provider this run hands the settings service when no data key exists: + * every call refuses with the reason. Composed so the service never builds a + * default provider of its own, which in a development posture mints a key. + */ +function refusingCryptoProvider(reason: string): ICryptoProvider { + const refuse = (): never => { + throw new Error(`No data key is available to this run, so nothing may be sealed or opened: ${reason}`); + }; + return { + encrypt: async () => refuse(), + decrypt: async () => refuse(), + rotateKey: async () => refuse(), + digest: () => refuse(), + keyedDigest: async () => refuse(), + }; +} + async function confirm(question: string): Promise { if (!process.stdin.isTTY) return false; // non-interactive → require --yes const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index 773f22e4f7f..d3e9395eea0 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -61,6 +61,7 @@ import MigrateResume from '../commands/migrate/resume.js'; import MigrateSummaryNulls from '../commands/migrate/summary-nulls.js'; import MigrateValueShapes from '../commands/migrate/value-shapes.js'; import SecretOrphans from '../commands/secret/orphans.js'; +import SecretRewrap from '../commands/secret/rewrap.js'; import StorageOrphans from '../commands/storage/orphans.js'; // [#10126] Pay the first transform of these dist-resolved workspace deps at @@ -216,6 +217,14 @@ const CALLERS: Record = { argv: ['--delete', '--export', '@DIR@/secret-export.json', '--no-declared-datasources', '--yes', '--database-url', '@DB@', '--json'], }], }, + 'commands/secret/rewrap.ts': { + run: invoke(SecretRewrap), + noWrite: [{ label: 'secret rewrap', argv: ['--database-url', '@DB@', '--json'] }], + write: [{ + label: 'secret rewrap --apply', + argv: ['--apply', '--no-declared-datasources', '--yes', '--database-url', '@DB@', '--json'], + }], + }, 'commands/storage/orphans.ts': { run: invoke(StorageOrphans), noWrite: [{ label: 'storage orphans', argv: ['--database-url', '@DB@', '--json'] }], @@ -397,7 +406,8 @@ beforeAll(async () => { delete process.env.OS_LIFECYCLE_DISABLED; process.env.NODE_ENV = 'production'; // The key this production-posture file needs — the served boot and every - // command that composes `SettingsServicePlugin` (`secret orphans`, and the + // command that composes `SettingsServicePlugin` (`secret orphans`, `secret + // rewrap`, which also resolves a provider of its own from it, and the // storage arm of `files-to-references` / `storage orphans`) construct a // `LocalCryptoProvider`, which refuses to start in production without one. // Declared here rather than inherited from a persisted diff --git a/packages/cli/test/json-stdout-purity.e2e.test.ts b/packages/cli/test/json-stdout-purity.e2e.test.ts index 2b249b4b8c5..c9aeae1ec05 100644 --- a/packages/cli/test/json-stdout-purity.e2e.test.ts +++ b/packages/cli/test/json-stdout-purity.e2e.test.ts @@ -109,6 +109,10 @@ const FAMILY: Record = { // `--delete` it boots, reports and writes nothing, so the family gains a // member without this fixture gaining a destructive run. 'secret orphans': [], + // A dry run is its DEFAULT and the only form driven here: without `--apply` + // it boots read-only and writes nothing, so the family gains a member + // without this fixture gaining a run that re-wraps anything. + 'secret rewrap': [], 'storage orphans': [], }; From 6a7c27c2f21c45433ea5261d705acd3c2763b127 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:39:46 +0000 Subject: [PATCH 8/9] docs(cli): os secret rewrap reference; changeset and ADR-0128 anchor for the re-wrap Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .changeset/21326-secret-rewrap.md | 47 ++++++++++++++++++ content/docs/deployment/cli.mdx | 48 +++++++++++++++++++ ...cli__src__utils__sys-secret-rewrap.ts.json | 7 +++ 3 files changed, 102 insertions(+) create mode 100644 .changeset/21326-secret-rewrap.md create mode 100644 scripts/adr-anchors/packages__cli__src__utils__sys-secret-rewrap.ts.json diff --git a/.changeset/21326-secret-rewrap.md b/.changeset/21326-secret-rewrap.md new file mode 100644 index 00000000000..0edb75d0190 --- /dev/null +++ b/.changeset/21326-secret-rewrap.md @@ -0,0 +1,47 @@ +--- +'@objectstack/cli': minor +'@objectstack/service-settings': minor +--- + +feat(cli): `os secret rewrap` re-wraps version-1 `sys_secret` ciphertext under the current AAD derivation, each row under its holder's producer scope (ADR-0128 §4.2, #21326 stage 2) + +Clause-②: no + +A ciphertext sealed before ADR-0128 D1–D3 carries the older binding over +`(namespace, key)` alone, and still opens in this release. `os secret rewrap` moves +the stored values to the current binding through `rotateKey`, the seam ADR-0128 §4 +names. It is an operator command: a dry run by default, `--apply` to write, and +nothing on any boot or upgrade path invokes it. It has no HTTP surface. + +- **The scope comes from the holder.** `sys_secret` records no producer, and a + version-1 ciphertext binds no scope, so each row is re-sealed under the scope of + the producer whose holder references it: `settings` for a `sys_setting.value_enc` + handle, `object_secret_field` for a `secret:` ref on a business row, + `datasource_credential` for a `sys_secret:` `credentialsRef`. The holders come from + the same cross-producer reference union `os secret orphans` reads. A row nothing + references, a row whose holders belong to different producers, and every row while + a holder family could not be read are left as they are and counted, never re-sealed + under a guessed scope. `--apply` refuses an incomplete union and names the family. +- **Resumable.** A row already sealed under the current derivation is skipped as + done, so a stopped run finishes the rest when re-run and a finished run writes + nothing. +- **Safe against a live deployment.** Each row is written by one conditional update, + keyed on its id and the ciphertext the run read. A row a producer changed in + between is not overwritten, and a re-run picks it up. A driver with no + `updateMany` is refused before any row is opened. +- **Fails closed.** A row that does not open, or whose re-seal does not open to the + same plaintext under the same scope, is not written. The run finishes the rest and + exits 1. The check happens before the write. +- **Output is classes and counts only.** It never prints a plaintext, a ciphertext + or a row id. + +The command resolves its data key from `OS_SECRET_KEY`, `OS_DEV_CRYPTO_KEY` or the +persisted key file, in the strict posture: it never mints a key, and it hands the +settings service it boots the same provider so that service does not mint one +either. With no key it refuses before opening any row. + +`@objectstack/service-settings` publishes `ciphertextDerivationStatus` (and its +`CiphertextDerivationStatus` type). It is `LocalCryptoProvider`'s own reading of +which derivation sealed a stored ciphertext, read off its marker without opening it: +`current`, `superseded` or `unknown`. The re-wrap classifies rows with it rather than +restating the marker grammar. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index c79083cff33..d2f6c080d07 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -577,6 +577,54 @@ named afterwards: the export carries the cipher material, is written owner-only, read back and checked before any row is removed. Keep it until you are certain. +#### `os secret rewrap` + +Re-wraps the `sys_secret` ciphertext sealed before secrets were bound to the producer +that wrote them, so it carries the current binding (ADR-0128). Each row is re-sealed +under the scope of the producer whose holder references it: a setting, an object's +`secret` field, or a datasource credential. **A dry run by default: without `--apply` +it writes nothing.** It is never run for you; nothing on any boot or upgrade path +invokes it. + +```bash +os secret rewrap --no-declared-datasources # dry run (writes nothing) +os secret rewrap --json --no-declared-datasources # the same, machine-readable +os secret rewrap --declared-datasources ./datasources.json +os secret rewrap --apply --no-declared-datasources # write the re-wrapped rows +``` + +**Options:** +- `--apply` — write the re-wrapped rows (default off) +- `--declared-datasources ` — JSON file (an array, or `{"datasources": [...]}`) of the datasource artefacts this host declares in code +- `--no-declared-datasources` — state that this host declares none +- `-y, --yes` — skip the confirmation prompt +- `--database-url `, `--json` + +Run it with the data key the deployment seals with (`OS_SECRET_KEY`, or the persisted +key file). It never mints a key, and with none it refuses before opening any row. + +What a run guarantees: + +- **The scope comes from the holder, never from a guess.** A row that nothing + references, a row whose holders belong to different producers, and every row while a + holder family could not be read are **left as they are** and counted. Like + `os secret orphans`, it reads all three holder families, and a host that says nothing + about its code-declared datasources leaves that family a gap. `--apply` then refuses + and names the family. +- **Resumable.** A row already sealed under the current binding is skipped as done. A run + that stopped part-way finishes the rest when re-run, and a finished run writes nothing. +- **Safe against a live deployment.** Each row is written in one statement, and only if + it still holds the ciphertext the run read. A row a producer changed during the run is + not overwritten. It is counted, and a re-run picks it up. +- **Fails closed.** A row that does not open, or whose re-seal does not open to the same + value under the same scope, is not written. The run finishes the rest and exits 1. + +The dry run opens, re-seals and verifies every attributed row in memory, so its counts +are the ones `--apply` would produce. Output is classes and counts only: re-wrap, +done, left (orphan, conflicting scope, union incomplete), refused (unreadable, unknown +derivation, verify failed) and, under `--apply`, not written (changed during the run, +write failed). It never prints a value, a ciphertext or a row id. + ### Build & Validate | Command | Description | diff --git a/scripts/adr-anchors/packages__cli__src__utils__sys-secret-rewrap.ts.json b/scripts/adr-anchors/packages__cli__src__utils__sys-secret-rewrap.ts.json new file mode 100644 index 00000000000..73a64f714b4 --- /dev/null +++ b/scripts/adr-anchors/packages__cli__src__utils__sys-secret-rewrap.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/cli/src/utils/sys-secret-rewrap.ts", + "adrs": [ + "ADR-0128" + ], + "invariant": "The at-rest re-wrap (ADR-0128 section 4.2) re-seals a version-1 sys_secret row through rotateKey under the scope of the producer whose holder references it, taken from the cross-producer reference union, never guessed: a row with no holder, with holders of different producers, or read while the union is incomplete is left as it is. A row already current is skipped as done, so the run is resumable. A re-seal is verified to open to the same plaintext under the same scope before it is written, and the write is one updateMany keyed on the row id and the ciphertext the run read, so a concurrent producer write is never overwritten. A row that does not open is refused and not written." +} From 219457c0298cbd7a8a88a42839df952da7554b67 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:03:06 +0000 Subject: [PATCH 9/9] =?UTF-8?q?fix(changeset):=20os=20secret=20rewrap=20de?= =?UTF-8?q?clares=20Clause-=E2=91=A1=20yes=20(widening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff grows two published surfaces: a root export on @objectstack/service-settings (ciphertextDerivationStatus and its type) and the os secret rewrap command on @objectstack/cli. The levels (minor, minor) already satisfy a widening declaration. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .changeset/21326-secret-rewrap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21326-secret-rewrap.md b/.changeset/21326-secret-rewrap.md index 0edb75d0190..a6c0af00b6a 100644 --- a/.changeset/21326-secret-rewrap.md +++ b/.changeset/21326-secret-rewrap.md @@ -5,7 +5,7 @@ feat(cli): `os secret rewrap` re-wraps version-1 `sys_secret` ciphertext under the current AAD derivation, each row under its holder's producer scope (ADR-0128 §4.2, #21326 stage 2) -Clause-②: no +Clause-②: yes (widening) A ciphertext sealed before ADR-0128 D1–D3 carries the older binding over `(namespace, key)` alone, and still opens in this release. `os secret rewrap` moves