clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

test(access): stage-0 spike for shared-field encryption (#156)

FelineStateMachine committed 766ae8e
Pins the three engine behaviors the shared-field design depends on,
against the real pinned server: a world-readable, self-scoped-write key
directory is expressible (impersonating inserts denied); wrapped-key rows
flow sender to recipient under anyOf/allOf scopes and stay invisible to
non-members (forged senders denied); and column transforms re-run at
materialization, so a sealed read that fails closed before key install
succeeds afterward with no schema redefinition — the pending-to-ready
mechanic shared fields rely on.
commit
766ae8e65a7a4a03f9a411c1ac568921bbe58f6f
parent
ea54b703e4889e780cedb2dddeddf40f293a6074
author
FelineStateMachine <dami.etoile@gmail.com>
1 file changed, +295 -0
Apackage/access/shared_fields_spike_sync_test.ts295++++++++++++++++++++++++++++++
A package/access/shared_fields_spike_sync_test.ts +295 -0
@@ -0,0 +1,295 @@
1+// Stage 0 spike for shared-field encryption (#154): pins the three engine2+// behaviors the design depends on, against the real pinned server.3+//4+// (a) Policy expressibility — a key-directory table can be world-readable5+// (`allowRead.always()`) while its writes stay self-scoped, and a6+// wrapped-key table can combine recipient scope with membership under7+// `anyOf`.8+// (b) Transform re-materialization — a column transform runs at every9+// materialization, so a read that fails before a key is installed10+// succeeds after installation with no schema redefinition.11+// (c) Wrapped-key data flow — a sender-written wrapped-key row syncs to its12+// recipient and stays invisible to a third account.13+//14+// The "wrap" here is a stand-in opaque string, not the authcrypt sealed box15+// (that is Stage 1 client crypto with no engine dependency); the spike's16+// subject is the engine.17+import { createDb, schema as s } from "jazz-tools";18+import { deploy, startLocalJazzServer } from "jazz-tools/testing";19+import {20+ clearEncryptedColumnKey,21+ encryptedText,22+ setEncryptedColumnKey,23+} from "../schema/encrypted.ts";24+import { EncryptedColumnError } from "../schema/encrypted.ts";25+import { assert } from "../runtime/test-assert.ts";26+27+const app = s.defineApp({28+ workspaces: s.table({ name: s.string() }),29+ workspaceMembers: s.table({30+ groupId: s.ref("workspaces"),31+ user_id: s.string(),32+ }),33+ keyDirectory: s.table({34+ user_id: s.string(),35+ algo: s.string(),36+ public_key: s.string(),37+ fingerprint: s.string(),38+ }),39+ fieldKeys: s.table({40+ groupId: s.ref("workspaces"),41+ recipient_user_id: s.string(),42+ sender_user_id: s.string(),43+ generation: s.int(),44+ wrapped_key: s.string(),45+ }),46+ vault: s.table({47+ label: s.string(),48+ secret: encryptedText("spike.vault.secret"),49+ }),50+});51+52+const permissions = s.definePermissions(app, (jazzContext) => {53+ const { policy, session, anyOf, allOf } = jazzContext as unknown as {54+ policy: Record<55+ string,56+ Record<string, { where(input: unknown): unknown; always(): unknown }> & {57+ exists: { where(input: unknown): unknown };58+ }59+ >;60+ session: { user_id: unknown };61+ anyOf(conditions: readonly unknown[]): unknown;62+ allOf(conditions: readonly unknown[]): unknown;63+ };64+65+ policy.workspaces.allowRead.always();66+ policy.workspaces.allowInsert.always();67+ policy.workspaces.allowUpdate.always();68+ policy.workspaces.allowDelete.always();69+70+ policy.workspaceMembers.allowRead.always();71+ policy.workspaceMembers.allowInsert.always();72+ policy.workspaceMembers.allowUpdate.always();73+ policy.workspaceMembers.allowDelete.always();74+75+ // (a) The directory: world-readable, self-scoped writes.76+ policy.keyDirectory.allowRead.always();77+ policy.keyDirectory.allowInsert.where({ user_id: session.user_id });78+ policy.keyDirectory.allowUpdate.where({ user_id: session.user_id });79+ policy.keyDirectory.allowDelete.where({ user_id: session.user_id });80+81+ const isMember = (groupId: unknown) =>82+ policy.workspaceMembers.exists.where({ groupId, user_id: session.user_id });83+84+ // (a) Wrapped keys: readable by the recipient or any member; writable by a85+ // member who signs as themselves.86+ policy.fieldKeys.allowRead.where((row: Record<string, unknown>) =>87+ anyOf([{ recipient_user_id: session.user_id }, isMember(row.groupId)])88+ );89+ policy.fieldKeys.allowInsert.where((row: Record<string, unknown>) =>90+ allOf([{ sender_user_id: session.user_id }, isMember(row.groupId)])91+ );92+ policy.fieldKeys.allowUpdate.where({ sender_user_id: session.user_id });93+ policy.fieldKeys.allowDelete.where({ sender_user_id: session.user_id });94+95+ policy.vault.allowRead.where({ $createdBy: session.user_id });96+ policy.vault.allowInsert.always();97+ policy.vault.allowUpdate.where({ $createdBy: session.user_id });98+ policy.vault.allowDelete.where({ $createdBy: session.user_id });99+});100+101+function secret(fill: number): string {102+ let binary = "";103+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);104+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");105+}106+107+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {108+ let timer: ReturnType<typeof setTimeout> | undefined;109+ try {110+ return await Promise.race([111+ operation,112+ new Promise<never>((_resolve, reject) => {113+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);114+ }),115+ ]);116+ } finally {117+ if (timer !== undefined) clearTimeout(timer);118+ }119+}120+121+Deno.test("stage-0 spike: directory policy, wrapped-key flow, and late-key reads", async () => {122+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });123+ await deploy({124+ appId: server.appId,125+ serverUrl: server.url,126+ adminSecret: server.adminSecret,127+ schema: app,128+ permissions,129+ });130+ const alice = await createDb({131+ appId: server.appId,132+ serverUrl: server.url,133+ secret: secret(1),134+ userBranch: "main",135+ driver: { type: "memory" },136+ });137+ const bob = await createDb({138+ appId: server.appId,139+ serverUrl: server.url,140+ secret: secret(2),141+ userBranch: "main",142+ driver: { type: "memory" },143+ });144+ const carol = await createDb({145+ appId: server.appId,146+ serverUrl: server.url,147+ secret: secret(3),148+ userBranch: "main",149+ driver: { type: "memory" },150+ });151+ try {152+ const aliceId = alice.getAuthState().session?.user_id as string;153+ const bobId = bob.getAuthState().session?.user_id as string;154+ const carolId = carol.getAuthState().session?.user_id as string;155+ assert(156+ Boolean(aliceId && bobId && carolId) && new Set([aliceId, bobId, carolId]).size === 3,157+ "test principals were not isolated",158+ );159+160+ // (a) Self-publish directory rows; a foreign read sees them (always()).161+ await within(162+ alice.insert(app.keyDirectory, {163+ user_id: aliceId,164+ algo: "x25519-v1",165+ public_key: "spike-alice-pub",166+ fingerprint: "spike-alice-fp",167+ }).wait({ tier: "global" }),168+ "alice directory publish",169+ );170+ const aliceRowSeenByBob = await within(171+ bob.all(app.keyDirectory.where({ user_id: aliceId })),172+ "bob reads alice's directory row",173+ );174+ assert(175+ aliceRowSeenByBob.length === 1 && aliceRowSeenByBob[0].public_key === "spike-alice-pub",176+ `allowRead.always() did not expose the directory row (saw ${aliceRowSeenByBob.length})`,177+ );178+179+ // (a) Foreign-identity writes to the directory must be denied.180+ let impersonationDenied = false;181+ try {182+ await within(183+ bob.insert(app.keyDirectory, {184+ user_id: aliceId,185+ algo: "x25519-v1",186+ public_key: "evil-pub",187+ fingerprint: "evil-fp",188+ }).wait({ tier: "global" }),189+ "bob impersonates alice in the directory",190+ );191+ } catch {192+ impersonationDenied = true;193+ }194+ assert(impersonationDenied, "self-scoped directory insert did not deny a foreign user_id");195+196+ // (c) Wrapped-key flow: Alice creates the group, enrolls both members,197+ // and wraps a key to Bob. Bob sees it; Carol does not.198+ const workspace = await within(199+ alice.insert(app.workspaces, { name: "spike" }).wait({ tier: "global" }),200+ "workspace insert",201+ );202+ await within(203+ alice.insert(app.workspaceMembers, { groupId: workspace.id, user_id: aliceId })204+ .wait({ tier: "global" }),205+ "alice membership",206+ );207+ await within(208+ alice.insert(app.workspaceMembers, { groupId: workspace.id, user_id: bobId })209+ .wait({ tier: "global" }),210+ "bob membership",211+ );212+ await within(213+ alice.insert(app.fieldKeys, {214+ groupId: workspace.id,215+ recipient_user_id: bobId,216+ sender_user_id: aliceId,217+ generation: 1,218+ wrapped_key: "wrap1.spike-opaque-wrapped-key-for-bob",219+ }).wait({ tier: "global" }),220+ "alice wraps for bob",221+ );222+ const bobWraps = await within(223+ bob.all(app.fieldKeys.where({ recipient_user_id: bobId })),224+ "bob reads his wrapped key",225+ );226+ assert(227+ bobWraps.length === 1 &&228+ bobWraps[0].wrapped_key === "wrap1.spike-opaque-wrapped-key-for-bob" &&229+ bobWraps[0].generation === 1,230+ `recipient-scoped wrapped-key read failed (saw ${bobWraps.length})`,231+ );232+ const carolWraps = await within(233+ carol.all(app.fieldKeys.where({ groupId: workspace.id } as never)),234+ "carol probes wrapped keys",235+ );236+ assert(237+ carolWraps.length === 0,238+ `a non-member saw ${carolWraps.length} wrapped-key rows — the anyOf scope leaks`,239+ );240+241+ // A forged sender identity must be denied even for a member.242+ let forgedSenderDenied = false;243+ try {244+ await within(245+ bob.insert(app.fieldKeys, {246+ groupId: workspace.id,247+ recipient_user_id: bobId,248+ sender_user_id: aliceId,249+ generation: 2,250+ wrapped_key: "wrap1.forged-sender",251+ }).wait({ tier: "global" }),252+ "bob forges alice as sender",253+ );254+ } catch {255+ forgedSenderDenied = true;256+ }257+ assert(forgedSenderDenied, "allOf sender scope did not deny a forged sender_id");258+259+ // (b) Late-key reads: with no key installed the sealed read fails closed;260+ // installing the key and re-materializing the same query succeeds without261+ // any schema or subscription redefinition. This is the pending→ready262+ // mechanic shared fields rely on.263+ setEncryptedColumnKey(new Uint8Array(32).map((_, index) => index + 1));264+ const sealed = await within(265+ alice.insert(app.vault, { label: "spike", secret: "the sealed value" })266+ .wait({ tier: "global" }),267+ "sealed insert",268+ );269+ clearEncryptedColumnKey();270+ let failedClosed = false;271+ try {272+ await within(alice.all(app.vault.where({ id: sealed.id })), "read without key");273+ } catch (error) {274+ failedClosed = error instanceof EncryptedColumnError && error.code === "key-missing";275+ }276+ assert(failedClosed, "a sealed read without an installed key did not fail closed");277+ setEncryptedColumnKey(new Uint8Array(32).map((_, index) => index + 1));278+ const reread = await within(279+ alice.all(app.vault.where({ id: sealed.id })),280+ "read after key install",281+ );282+ assert(283+ reread.length === 1 && reread[0].secret === "the sealed value",284+ "re-materialization after key install did not decrypt",285+ );286+ } finally {287+ clearEncryptedColumnKey();288+ await Promise.allSettled([289+ within(alice.logout(), "Alice cleanup", 3_000),290+ within(bob.logout(), "Bob cleanup", 3_000),291+ within(carol.logout(), "Carol cleanup", 3_000),292+ ]);293+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);294+ }295+});