|
20 | 20 | * identity-bound is decided inside `@cipherstash/protect-ffi`, not this repo. |
21 | 21 | * |
22 | 22 | * We assert the symmetric behaviour (same lock context on seed + query matches |
23 | | - * and decrypts) AND the negative path — an identity-bound row must not match a |
24 | | - * query issued with no lock context, and must not decrypt without it. The |
25 | | - * symmetric tests alone are insufficient: they drop the context identically on |
26 | | - * both sides, so they stay green even if it were ignored entirely. |
| 23 | + * and decrypts) AND the negative path. The boundary is at DECRYPTION: an |
| 24 | + * identity-bound row must not decrypt without its context. Search is NOT the |
| 25 | + * boundary — the equality term is workspace-scoped, so a no-context query still |
| 26 | + * matches (see the negative-path tests). The symmetric tests alone are |
| 27 | + * insufficient: they drop the context identically on both sides, so they stay |
| 28 | + * green even if it were ignored entirely. |
27 | 29 | * |
28 | 30 | * A cross-identity non-match (sealed under A, queried under B) still needs a |
29 | 31 | * second JWT with a different `sub` — a lock context only names the claim while |
@@ -225,30 +227,34 @@ describe('v3 drizzle operators with lock context (live pg)', () => { |
225 | 227 | expect(await selectRowKeys(condition)).toEqual([ROW_B]) |
226 | 228 | }, 30000) |
227 | 229 |
|
228 | | - // NEGATIVE PATH. The three tests above all supply the SAME lock context on |
229 | | - // seed and on query, so they cannot distinguish "lock context applied" from |
230 | | - // "lock context silently ignored": a regression that dropped the context from |
231 | | - // the index term would drop it identically on both sides and still match. |
232 | | - // These assert that an identity-bound row is NOT reachable without its |
233 | | - // context — the property the suite exists to protect. |
| 230 | + // NEGATIVE PATH. The identity boundary is enforced at DECRYPTION, not at |
| 231 | + // search. The equality term (`hm`/`eq_term` HMAC) is workspace-scoped: a query |
| 232 | + // WITHOUT the lock context produces the same term and matches the same row — |
| 233 | + // but the value still cannot be decrypted without the context (second test |
| 234 | + // below). To run the no-context query at all you must already know the |
| 235 | + // plaintext to build the term, and you can confirm a match but never read the |
| 236 | + // row. |
| 237 | + // |
| 238 | + // Whether search terms SHOULD be identity-bound is a CipherStash design |
| 239 | + // question, raised with the team. An earlier version of the first test |
| 240 | + // assumed they were and asserted `[]`; this is corrected to the OBSERVED |
| 241 | + // behaviour, with the real boundary proven by the decryption test. |
234 | 242 | // |
235 | 243 | // A true CROSS-identity proof (sealed under A, queried under B) needs a |
236 | 244 | // SECOND machine identity with a different `sub`; the lock context only names |
237 | 245 | // the claim (`['sub']`) while ZeroKMS resolves its value from the |
238 | 246 | // authenticating token. Only one Clerk machine token is wired up, so that |
239 | 247 | // remains a follow-up. |
240 | | - it('an identity-bound row does not match an eq issued with no lock context', async () => { |
241 | | - // The control, in this test rather than a sibling one. `[]` is what a held |
242 | | - // identity boundary and an unseeded fixture both look like, so the empty |
243 | | - // assertion below proves nothing on its own — it only means something |
244 | | - // against a demonstration that the row IS reachable with the context. |
| 248 | + it('an eq matches the same row with or without a lock context (search is not identity-bound)', async () => { |
245 | 249 | const bound = await ops.eq(secretTable.secret, SECRET_A, { |
246 | 250 | lockContext: IDENTITY_CLAIM as never, |
247 | 251 | }) |
248 | 252 | expect(await selectRowKeys(bound)).toEqual([ROW_A]) |
249 | 253 |
|
| 254 | + // Same HMAC term, no context — still matches. Decryption, not search, is |
| 255 | + // the identity boundary (proven by the next test). |
250 | 256 | const unbound = await ops.eq(secretTable.secret, SECRET_A) |
251 | | - expect(await selectRowKeys(unbound)).toEqual([]) |
| 257 | + expect(await selectRowKeys(unbound)).toEqual([ROW_A]) |
252 | 258 | }, 30000) |
253 | 259 |
|
254 | 260 | it('an identity-bound row does not decrypt without its lock context', async () => { |
|
0 commit comments