Skip to content

Commit fbc051f

Browse files
committed
test(stack): add a cross-identity lock-context test (second Clerk machine)
Proves the property the single-identity tests can't: a row sealed under identity A must not decrypt under a DIFFERENT identity B. B is a second Clerk machine (a distinct `sub`) in the same Clerk instance, federated via a new `CLERK_MACHINE_TOKEN_B` secret. The test decrypts A's row as A (control, must succeed) and as B (must be denied with a key/identity error, not an infra fault). - clerkJwtProvider(tokenEnvVar='CLERK_MACHINE_TOKEN') is now parameterized and self-asserts its token (throws, never skips), so a second identity is one argument. The now-unused `clerk` requirement is dropped from test-kit env. - CI: the Drizzle job passes CLERK_MACHINE_TOKEN_B (job env only, not the preflight — a missing B fails only the cross-identity test). Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent 0537e63 commit fbc051f

4 files changed

Lines changed: 81 additions & 28 deletions

File tree

.github/workflows/integration-drizzle.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,13 @@ jobs:
7373
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
7474
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
7575
# The identity suites federate a freshly-minted Clerk M2M JWT into a CTS
76-
# token. Only they read this; the other suites ignore it. If it is unset
77-
# they fail loudly (throw, never skip) rather than taking the whole job
78-
# down, so it is NOT part of the require-cs-secrets preflight.
76+
# token. Only they read these; the other suites ignore them. If one is
77+
# unset the relevant suite fails loudly (throw, never skip) rather than
78+
# taking the whole job down, so they are NOT in the require-cs-secrets
79+
# preflight. `_B` is a SECOND machine (a distinct `sub`) in the same Clerk
80+
# instance, used only by the cross-identity test.
7981
CLERK_MACHINE_TOKEN: ${{ secrets.CLERK_MACHINE_TOKEN }}
82+
CLERK_MACHINE_TOKEN_B: ${{ secrets.CLERK_MACHINE_TOKEN_B }}
8083
# Job-level env, not a `.env` file: `dotenv/config` does not override an
8184
# already-set `process.env`, so these win and no secret is written to disk.
8285
DATABASE_URL: ${{ matrix.database-url }}

packages/stack/integration/helpers/clerk.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { requireIntegrationEnv } from '@cipherstash/test-kit'
21
import { createClerkClient } from '@clerk/backend'
32

43
/**
@@ -11,17 +10,27 @@ import { createClerkClient } from '@clerk/backend'
1110
* in an env var or a CI secret. The only secret is `CLERK_MACHINE_TOKEN`, a
1211
* rotatable machine key.
1312
*
14-
* The federated identity is the Clerk machine (`CTS|CS|mch_…`), which is stable
15-
* across encrypt and decrypt — enough for the symmetric round-trip and the
16-
* no-context negative path. The cross-identity test needs a SECOND machine
17-
* token (a distinct `sub`) and remains a follow-up.
13+
* The federated identity is the Clerk machine (`CTS|CS|mch_…`), stable across
14+
* encrypt and decrypt. Pass a different `tokenEnvVar` (e.g. a second machine's
15+
* `CLERK_MACHINE_TOKEN_B`) to federate as a DISTINCT identity — that is what the
16+
* cross-identity test uses to prove a row sealed under one machine does not
17+
* decrypt under another.
1818
*
1919
* The workspace named by `CS_WORKSPACE_CRN` must have this Clerk instance
2020
* registered on its OIDC-providers page, or `/api/authorise` rejects the token.
2121
*/
22-
export function clerkJwtProvider(): () => Promise<string> {
23-
requireIntegrationEnv(['clerk'])
24-
const machineSecretKey = process.env['CLERK_MACHINE_TOKEN'] as string
22+
export function clerkJwtProvider(
23+
tokenEnvVar = 'CLERK_MACHINE_TOKEN',
24+
): () => Promise<string> {
25+
const machineSecretKey = process.env[tokenEnvVar]
26+
if (!machineSecretKey) {
27+
throw new Error(
28+
`Integration suite cannot run — missing ${tokenEnvVar} (a Clerk machine ` +
29+
'secret key, ak_...). Its Clerk instance must be registered on the ' +
30+
'CS_WORKSPACE_CRN workspace OIDC-providers page. This suite FAILS rather ' +
31+
'than skips: a green skip would hide a real regression.',
32+
)
33+
}
2534

2635
// `createToken` authenticates with `machineSecretKey`; `createClerkClient`
2736
// still wants a `secretKey` string at construction that this path never uses.

packages/stack/integration/identity/lock-context.integration.test.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
* insufficient: they drop the context identically on both sides, so they stay
2828
* green even if it were ignored entirely.
2929
*
30-
* A cross-identity non-match (sealed under A, queried under B) still needs a
31-
* second JWT with a different `sub` — a lock context only names the claim while
32-
* ZeroKMS resolves its value from the authenticating token. Tracked separately.
30+
* The cross-identity non-match (sealed under A, decrypted under B) is covered by
31+
* the final test: it federates a SECOND Clerk machine (`CLERK_MACHINE_TOKEN_B`,
32+
* a distinct `sub`) and asserts B cannot read A's row, with A reading it as the
33+
* control.
3334
*/
3435
import { OidcFederationStrategy } from '@cipherstash/auth'
3536
import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit'
@@ -240,11 +241,11 @@ describe('v3 drizzle operators with lock context (live pg)', () => {
240241
// assumed they were and asserted `[]`; this is corrected to the OBSERVED
241242
// behaviour, with the real boundary proven by the decryption test.
242243
//
243-
// A true CROSS-identity proof (sealed under A, queried under B) needs a
244+
// The true CROSS-identity proof (sealed under A, decrypted under B) needs a
244245
// SECOND machine identity with a different `sub`; the lock context only names
245246
// the claim (`['sub']`) while ZeroKMS resolves its value from the
246-
// authenticating token. Only one Clerk machine token is wired up, so that
247-
// remains a follow-up.
247+
// authenticating token. That is the final test in this suite, federating
248+
// `CLERK_MACHINE_TOKEN_B`.
248249
it('an eq matches the same row with or without a lock context (search is not identity-bound)', async () => {
249250
const bound = await ops.eq(secretTable.secret, SECRET_A, {
250251
lockContext: IDENTITY_CLAIM as never,
@@ -300,4 +301,54 @@ describe('v3 drizzle operators with lock context (live pg)', () => {
300301
expect(message).toMatch(IDENTITY_DENIAL)
301302
expect(message).not.toMatch(INFRA_FAULT)
302303
}, 30000)
304+
305+
// The true cross-identity proof: same `['sub']` claim, a DIFFERENT
306+
// authenticating machine. Everything above uses one identity, so it cannot
307+
// tell "bound to A" from "bound to nothing" — this can. Machine B must be a
308+
// distinct machine (its own `sub`) in the same Clerk instance registered on
309+
// the workspace; `clerkJwtProvider('CLERK_MACHINE_TOKEN_B')` throws if that
310+
// token is unset, so this fails loudly rather than skipping.
311+
it('a row sealed under identity A does not decrypt under a different identity (B)', async () => {
312+
const crn = process.env.CS_WORKSPACE_CRN as string
313+
const federationB = OidcFederationStrategy.create(
314+
crn,
315+
clerkJwtProvider('CLERK_MACHINE_TOKEN_B'),
316+
)
317+
if (federationB.failure) {
318+
throw new Error(`[federation B]: ${federationB.failure.message}`)
319+
}
320+
const clientB = await EncryptionV3({
321+
schemas: [schema],
322+
config: { authStrategy: federationB.data },
323+
})
324+
325+
const [row] = await sqlClient.unsafe<Array<{ value: unknown }>>(
326+
`SELECT secret::jsonb AS value FROM ${TABLE_NAME}
327+
WHERE test_run_id = $1 AND row_key = $2`,
328+
[RUN, ROW_A],
329+
)
330+
expect(row).toBeDefined()
331+
332+
// Control: identity A reads its own row. If this is denied, A's federation
333+
// or the fixture is broken and the cross-identity assertion below would
334+
// "pass" for the wrong reason.
335+
const asA = await decryptOutcome(() =>
336+
client.decrypt(row.value as never).withLockContext(IDENTITY_CLAIM),
337+
)
338+
expect(
339+
asA.denied,
340+
`identity A must read its own row (got: ${asA.message})`,
341+
).toBe(false)
342+
343+
// Cross-identity: B names the same `['sub']` claim, but its value is B's
344+
// machine, so ZeroKMS cannot reproduce A's key.
345+
const asB = await decryptOutcome(() =>
346+
clientB.decrypt(row.value as never).withLockContext(IDENTITY_CLAIM),
347+
)
348+
expect(asB.denied, 'identity B must NOT decrypt a row sealed under A').toBe(
349+
true,
350+
)
351+
expect(asB.message).toMatch(IDENTITY_DENIAL)
352+
expect(asB.message).not.toMatch(INFRA_FAULT)
353+
}, 30000)
303354
})

packages/test-kit/src/env.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { join } from 'node:path'
1212
* to fix it.
1313
*/
1414

15-
export type Requirement = 'cipherstash' | 'database' | 'pgrest' | 'clerk'
15+
export type Requirement = 'cipherstash' | 'database' | 'pgrest'
1616

1717
/** Every variable protect-ffi's `AutoStrategy` needs to authenticate from the environment. */
1818
const CS_VARS = [
@@ -87,16 +87,6 @@ const HINTS: Record<Requirement, () => string | null> = {
8787
: 'PGRST_URL. The Supabase adapter speaks PostgREST, not Postgres:\n' +
8888
' docker compose -f local/docker-compose.supabase.yml up -d --wait\n' +
8989
' export PGRST_URL=http://localhost:55430',
90-
91-
clerk: () =>
92-
process.env['CLERK_MACHINE_TOKEN']
93-
? null
94-
: 'CLERK_MACHINE_TOKEN. The identity / lock-context suites federate a\n' +
95-
' freshly-minted Clerk machine (M2M) JWT into a CTS token, so they need\n' +
96-
" the machine's secret key (ak_...):\n" +
97-
' export CLERK_MACHINE_TOKEN=ak_...\n' +
98-
' The CS_WORKSPACE_CRN workspace must have that Clerk instance registered\n' +
99-
' on its OIDC-providers page.',
10090
}
10191

10292
/**

0 commit comments

Comments
 (0)