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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/crypto-posture-deployment-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
---
"@objectstack/service-settings": minor
---

**Security:** `LocalCryptoProvider` selects its crypto posture from the deployment signal only. A test-runner variable inherited by a spawned server can no longer disarm the production key refusal (#11352).

`detectMode` read `env.VITEST` as a vote for `'test'` posture:

```ts
if (env.VITEST || env.NODE_ENV === 'test') return 'test';
```

`'test'` is not a softer flavour of `'production'`. It is the branch that takes an ephemeral key, never touches disk, and **never refuses to boot** — and that refusal is the reason the class exists: minting a key at boot makes every previously-written `sys_secret` value (encrypted settings, `secret` fields, datasource credentials) undecryptable after the next restart or on another node, invisibly at encrypt time. So one runner variable decided whether a security gate ran at all.

Runner variables are **inherited**. Vitest sets `TEST`, `VITEST`, `VITEST_MODE`, `VITEST_WORKER_ID` and `VITEST_POOL_ID` on its worker, and every process that worker spawns with `{ ...process.env }` receives them. Measured on this repo: a real `os serve` spawned that way booted with production auth and **test** crypto. `packages/cli/test/serve-node-env-production-default.e2e.test.ts` — a pin whose entire subject is *"unset `NODE_ENV` means production"* — ran that way for its whole life, and nothing said a word, because a gate that does not run prints nothing. It surfaced only incidentally, while closing the sibling `TEST` leak into better-auth's origin check one layer down.

**What changes for you.** A process that boots with `NODE_ENV=production`, no `OS_SECRET_KEY`/`OS_DEV_CRYPTO_KEY`, no persisted key file, no `OS_CRYPTO_AUTOKEY` — and a runner variable in its environment — now **refuses to start** instead of running on an ephemeral key. That is the documented fail-loud guarantee arriving where it was previously skipped, not a new restriction: supply the key the refusal names.

```
OS_SECRET_KEY=$(openssl rand -hex 32)
```

**What does not change.** In-process unit tests still get `test` posture — ephemeral key, disk never touched. The `VITEST` read is deleted rather than narrowed because vitest sets both variables on the same worker (`prepareVitest()`: `process.env.VITEST = "true"; process.env.NODE_ENV ??= "test";`, repeated as `NODE_ENV: process.env.NODE_ENV || "test"` in each worker's env). In-process the two spellings are indistinguishable; they differ only for an **inheriting child**, which is precisely the defect. `NODE_ENV` remains the one signal, and a deployment that declares itself a test deployment still gets test posture.

The whole class is now gated: `pnpm check:runner-env-posture` refuses `TEST`, `VITEST`/`VITEST_*` and `JEST_WORKER_ID` anywhere in product source, so the next author is told at authoring time rather than by an operator whose secrets stopped decrypting. `NODE_ENV` is deliberately not banned — it describes the deployment, and a deployment may declare itself a test deployment; a runner may not declare it on the deployment's behalf.
14 changes: 14 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -1198,6 +1198,20 @@ jobs:
- name: Single authz resolver guard
run: pnpm check:authz-resolver

# #11352. Product code may not read a test-RUNNER variable (`TEST`,
# `VITEST*`, `JEST_WORKER_ID`). Runner variables are INHERITED by every
# process the runner spawns, so a product decision keyed off one is made
# for spawned SERVERS too: `local-crypto-provider.ts` selected its crypto
# posture with `if (env.VITEST || ...)`, and `'test'` posture is the branch
# that never refuses to boot without a stable key. A real `os serve`
# spawned from a vitest worker therefore ran with production auth and test
# crypto — invisibly, because a gate that does not run prints nothing.
# `NODE_ENV` is deliberately NOT banned: it describes the deployment.
# Dependency-free source scan over `**/src/**`, comments and literals
# masked; runs its own --self-test first.
- name: Runner-env posture guard
run: pnpm check:runner-env-posture

# #4093 follow-up. Discovery tells a consumer an absent capability is
# absent AND what to install. The first half has been carefully honest
# since #2462/#4000; the second was invented from the slot name, so ten
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,7 @@
"check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs",
"check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs",
"check:org-identifier": "node scripts/check-org-identifier.mjs --self-test && node scripts/check-org-identifier.mjs",
"check:runner-env-posture": "node scripts/check-runner-env-posture.mjs --self-test && node scripts/check-runner-env-posture.mjs",
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs --self-test && node scripts/check-single-authz-resolver.mjs",
"check:slot-lookup": "node scripts/check-slot-lookup-ratchet.mjs",
"check:query-options-erasure": "node scripts/check-query-options-erasure-ratchet.mjs --self-test && node scripts/check-query-options-erasure-ratchet.mjs",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #11352 — crypto posture is selected from the DEPLOYMENT signal, never from a
* test-RUNNER variable.
*
* ## What this pins, and why it is not the same as "which key was picked"
*
* `detectMode()` decides whether `LocalCryptoProvider`'s fail-loud guarantee is
* ARMED. `'test'` is not a softer flavour of `'production'` — it is the branch
* that takes an ephemeral key, never touches disk, and **never refuses to
* boot**. The refusal IS the gate, so this file is graded on whether the
* refusal is present, not on which key material a boot ended up with.
*
* Before this card, `detectMode` read:
*
* if (env.VITEST || env.NODE_ENV === 'test') return 'test';
*
* `VITEST` describes the RUNNER, and a runner variable is inherited by every
* process the runner spawns. A real `os serve` spawned from a vitest worker
* with `{ ...process.env }` therefore booted with its crypto layer in `test`
* mode — no stable key, no disk, no refusal — however production-shaped the
* rest of that boot was.
*
* ## Why the environment here is a COPY of the real worker env
*
* A spawned child does not receive a hand-written fixture; it receives a copy
* of its parent's `process.env`. So the map every case below starts from is
* exactly that — `{ ...process.env }`, this vitest worker's own environment,
* runner variables and all — with a single deliberate mutation per case. A
* hand-written `{ VITEST: 'true' }` would pin the variable this file happens to
* know about today; a copy pins whatever the runner actually exports.
*
* `carriesRunnerVariables` below is the anti-vacuity control: if the worker
* stopped exporting runner variables altogether, every "a leaked runner
* variable does not move posture" case would pass while measuring nothing, so
* the file says so out loud instead.
*/

import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';

import { LocalCryptoProvider } from './local-crypto-provider.js';

/**
* The runner-variable class, spelled the same way
* `packages/cli/test/helpers/serve-process.ts` spells it: `TEST` exactly, plus
* `VITEST` and anything `VITEST_`-prefixed. `JEST_WORKER_ID` rides along
* because the class is "a variable that says a RUNNER is present", not "the
* runner this repo uses today".
*/
const RUNNER_ENV_KEYS = [
'TEST',
'VITEST',
'VITEST_WORKER_ID',
'VITEST_POOL_ID',
'VITEST_MODE',
'JEST_WORKER_ID',
] as const;

type EnvMap = Record<string, string | undefined>;

/** This worker's REAL environment — what any child it spawned would inherit. */
const workerEnv = (): EnvMap => ({ ...process.env } as EnvMap);

/** Strip every key-bearing variable, so each case exercises the no-stable-key path. */
const withoutKeySources = (env: EnvMap): EnvMap => ({
...env,
OS_SECRET_KEY: undefined,
OS_DEV_CRYPTO_KEY: undefined,
OBJECTSTACK_DEV_CRYPTO_KEY: undefined,
OS_CRYPTO_AUTOKEY: undefined,
});

describe('#11352 — crypto posture reads the deployment signal, not the runner', () => {
let home: string;
let base: EnvMap;

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'os-crypto-posture-'));
// `OS_HOME` and `HOME` both pinned at an empty dir: no operator-provisioned
// key file exists for any case, which is the state the refusal is about.
base = withoutKeySources({ ...workerEnv(), HOME: home, OS_HOME: home });
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});

const keyFile = () => join(home, 'dev-crypto-key');

it('is measuring something: this worker really does export runner variables', () => {
const carriesRunnerVariables = RUNNER_ENV_KEYS.filter((k) => process.env[k] !== undefined);
expect(
carriesRunnerVariables.length,
'no runner variable is present in this worker, so every leak case below would pass vacuously',
).toBeGreaterThan(0);
// The specific one this card is about, and the one a spawned child inherits.
expect(process.env.VITEST).toBeDefined();
});

it('in-process unit tests still get test posture — carried by NODE_ENV, not by VITEST', () => {
// The half that must NOT move. vitest sets `NODE_ENV ??= 'test'` on the
// same worker it sets `VITEST=true` on, so the documented in-process
// intent survives the runner variable losing its vote.
expect(process.env.NODE_ENV).toBe('test');

const p = new LocalCryptoProvider({ env: base });
expect(p.keySource).toBe('ephemeral');
expect(existsSync(keyFile()), 'test posture must never touch disk').toBe(false);
});

it('a deployment in production posture REFUSES to boot without a stable key', () => {
// THE CARD. Identical map to the case above — this worker's own env,
// runner variables included — with the deployment signal set to what a real
// `os serve` deployment carries. Before #11352 the inherited `VITEST=true`
// won this decision and the boot SUCCEEDED on an ephemeral key.
const env: EnvMap = { ...base, NODE_ENV: 'production' };
expect(env.VITEST, 'the leak is still in the map — that is the point').toBeDefined();

expect(() => new LocalCryptoProvider({ env })).toThrow(/Refusing to start in production/);
expect(existsSync(keyFile()), 'a refused boot must not have minted a key').toBe(false);
});

it('still resolves a real production key when one IS provisioned', () => {
// The refusal is a gate, not a wall: production posture with a stable key
// boots, runner variables present or not.
const hex = randomBytes(32).toString('hex');
const p = new LocalCryptoProvider({ env: { ...base, NODE_ENV: 'production', OS_SECRET_KEY: hex } });
expect(p.keySource).toBe('env:OS_SECRET_KEY');
});

describe('no runner variable moves the answer, in any deployment posture', () => {
// A table rather than one case per variable: the defect class is "a runner
// variable votes", so the pin has to be that NONE of them does, in EVERY
// posture — including the postures where the wrong answer would look benign.
const postures = [
['production', 'production'],
['development', 'development'],
['test', 'test'],
] as const;

for (const [nodeEnv] of postures) {
for (const runnerKey of RUNNER_ENV_KEYS) {
it(`NODE_ENV=${nodeEnv} is unchanged by ${runnerKey}`, () => {
const clean: EnvMap = { ...base, NODE_ENV: nodeEnv };
for (const k of RUNNER_ENV_KEYS) clean[k] = undefined;
const leaked: EnvMap = { ...clean, [runnerKey]: 'true' };

expect(outcomeOf(leaked)).toBe(outcomeOf(clean));
});
}
}

/**
* Collapse a construction to the only two things this gate is about:
* did it REFUSE, and if it booted, from where did the key come.
* A fresh temp home per call so one case's minted dev key is never the
* next case's `source: 'file'`.
*/
function outcomeOf(env: EnvMap): string {
const scratch = mkdtempSync(join(tmpdir(), 'os-crypto-outcome-'));
try {
const p = new LocalCryptoProvider({ env: { ...env, HOME: scratch, OS_HOME: scratch } });
return `booted:${p.keySource}`;
} catch (err) {
return `refused:${String((err as Error).message).split('\n')[0]}`;
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
});
});
59 changes: 56 additions & 3 deletions packages/services/service-settings/src/local-crypto-provider.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,9 +56,11 @@ import { dirname, join } from 'node:path';
* running under a key that won't survive a restart. Development and test keep
* the ergonomic fallback so local loops and unit tests stay frictionless.
*
* `mode` is auto-detected from `NODE_ENV` (`production` → strict;
* `test`/`VITEST` → ephemeral, no disk; otherwise `development`) and can be
* `mode` is auto-detected from `NODE_ENV` alone (`production` → strict;
* `test` → ephemeral, no disk; otherwise `development`) and can be
* overridden via `opts.mode` for embedders that manage their own lifecycle.
* Which variables may and may not decide that is spelled out at `detectMode`
* below — it is a security question, not a formatting one.
*
* ## Handle format
* id — `sec_` + 32 hex chars (122 bits of entropy)
Expand DownExpand Up@@ -129,8 +131,59 @@ export interface LocalCryptoProviderOptions {
const processEnv = (): EnvMap =>
((globalThis as { process?: { env?: EnvMap } }).process?.env ?? {}) as EnvMap;

/**
* Deployment posture — read from `NODE_ENV` and from NOTHING ELSE.
*
* ## ⛔ Never widen this to a test-RUNNER variable
*
* This function decides whether the fail-loud guarantee documented above is
* ARMED. `'test'` is not a softer flavour of `'production'`: it is the branch
* that takes an ephemeral key, never touches disk, and — the part that matters
* — never refuses to boot. **The refusal is the gate.** So a variable that can
* reach this function decides whether a security gate runs, and the only
* variables allowed to do that are the ones that describe the DEPLOYMENT.
*
* This line used to read:
*
* if (env.VITEST || env.NODE_ENV === 'test') return 'test';
*
* `VITEST` describes the RUNNER, not the deployment, and a runner variable is
* INHERITED by every process the runner spawns. Measured on this repo: a real
* `os serve` spawned from a vitest worker with `{ ...process.env }` carried
* `VITEST=true` into the child, so that boot's crypto layer sat in `test` mode
* — ephemeral key, no disk, no refusal — while the rest of the boot was in
* production posture. `packages/cli/test/serve-node-env-production-default`
* `.e2e.test.ts`, a pin whose entire subject is *"unset `NODE_ENV` means
* production"*, ran that way for its whole life: production for auth, test for
* crypto. Nothing said a word, because a gate that does not run prints nothing.
*
* ## Why deleting it does not move in-process unit tests
*
* Reading `VITEST` was intentional: in-process unit tests must get `test`
* posture so they neither mint a key file in `$HOME` nor fail on a machine
* without one. That intent is preserved here rather than dropped, because
* vitest sets BOTH variables on the same worker — measured in vitest 4.1.10's
* own source, `prepareVitest()`:
*
* process.env.TEST = "true";
* process.env.VITEST = "true";
* process.env.NODE_ENV ??= "test";
*
* and it repeats `NODE_ENV: process.env.NODE_ENV || "test"` in the env it hands
* each worker. So an in-process test already satisfies `NODE_ENV === 'test'`
* and lands on this function's first line without `VITEST` participating at
* all. The two spellings are indistinguishable IN-PROCESS and differ only for
* an INHERITING child — which is precisely the defect. `local-crypto-provider`
* `.test.ts` pins both halves: the in-process posture (a real worker, real
* `process.env`, no injected map) and the refusal under a leaked runner
* variable.
*
* `pnpm check:runner-env-posture` keeps the whole class shut, so the next
* author who reaches for a runner variable in product code is told here rather
* than by an operator whose secrets stopped decrypting.
*/
const detectMode = (env: EnvMap): CryptoMode => {
if (env.VITEST || env.NODE_ENV === 'test') return 'test';
if (env.NODE_ENV === 'test') return 'test';
if (env.NODE_ENV === 'production') return 'production';
return 'development';
};
Expand Down
Loading
Loading