clone

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

feat/141-demo-review-fixes
lofi / package / access / shared_fields_spike_sync_test.ts
shared_fields_spike_sync_test.ts 10.5K
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.17import { createDb, schema as s } from "jazz-tools";18import { deploy, startLocalJazzServer } from "jazz-tools/testing";19import {20 clearEncryptedColumnKey,21 encryptedText,22 setEncryptedColumnKey,23} from "../schema/encrypted.ts";24import { EncryptedColumnError } from "../schema/encrypted.ts";25import { assert } from "../runtime/test-assert.ts";2627const 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});5152const 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 };6465 policy.workspaces.allowRead.always();66 policy.workspaces.allowInsert.always();67 policy.workspaces.allowUpdate.always();68 policy.workspaces.allowDelete.always();6970 policy.workspaceMembers.allowRead.always();71 policy.workspaceMembers.allowInsert.always();72 policy.workspaceMembers.allowUpdate.always();73 policy.workspaceMembers.allowDelete.always();7475 // (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 });8081 const isMember = (groupId: unknown) =>82 policy.workspaceMembers.exists.where({ groupId, user_id: session.user_id });8384 // (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 });9495 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});100101function 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}106107async 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}120121Deno.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 );159160 // (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 );178179 // (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");195196 // (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 );240241 // 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");258259 // (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});