diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index 621d579..714553b 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -34,6 +34,7 @@ import { mesh, meshFile, meshFileAccess, + meshFileKey, meshContext, meshMember as memberTable, meshMemory, @@ -717,6 +718,8 @@ export async function uploadFile(args: { uploadedByMember?: string; targetSpec?: string; expiresAt?: Date; + encrypted?: boolean; + ownerPubkey?: string; }): Promise { const [row] = await db .insert(meshFile) @@ -732,6 +735,8 @@ export async function uploadFile(args: { uploadedByMember: args.uploadedByMember ?? null, targetSpec: args.targetSpec ?? null, expiresAt: args.expiresAt ?? null, + encrypted: args.encrypted ?? false, + ownerPubkey: args.ownerPubkey ?? null, }) .returning({ id: meshFile.id }); if (!row) throw new Error("failed to insert file row"); @@ -755,6 +760,8 @@ export async function getFile( uploadedByName: string | null; targetSpec: string | null; uploadedAt: Date; + encrypted: boolean; + ownerPubkey: string | null; } | null> { const [row] = await db .select({ @@ -768,6 +775,8 @@ export async function getFile( uploadedByName: meshFile.uploadedByName, targetSpec: meshFile.targetSpec, uploadedAt: meshFile.uploadedAt, + encrypted: meshFile.encrypted, + ownerPubkey: meshFile.ownerPubkey, }) .from(meshFile) .where( @@ -782,6 +791,8 @@ export async function getFile( return { ...row, tags: (row.tags ?? []) as string[], + encrypted: row.encrypted, + ownerPubkey: row.ownerPubkey, }; } @@ -801,6 +812,7 @@ export async function listFiles( uploadedBy: string; uploadedAt: Date; persistent: boolean; + encrypted: boolean; }> > { const conditions = [ @@ -822,6 +834,7 @@ export async function listFiles( uploadedByName: meshFile.uploadedByName, uploadedAt: meshFile.uploadedAt, persistent: meshFile.persistent, + encrypted: meshFile.encrypted, }) .from(meshFile) .where(and(...conditions)) @@ -835,6 +848,7 @@ export async function listFiles( uploadedBy: r.uploadedByName ?? "unknown", uploadedAt: r.uploadedAt, persistent: r.persistent, + encrypted: r.encrypted, })); } @@ -892,6 +906,52 @@ export async function deleteFile( ); } +/** Insert encrypted key blobs for a newly uploaded E2E file. */ +export async function insertFileKeys( + fileId: string, + keys: Array<{ peerPubkey: string; sealedKey: string; grantedByPubkey?: string }>, +): Promise { + if (keys.length === 0) return; + await db.insert(meshFileKey).values( + keys.map((k) => ({ + fileId, + peerPubkey: k.peerPubkey, + sealedKey: k.sealedKey, + grantedByPubkey: k.grantedByPubkey ?? null, + })), + ); +} + +/** Get the sealed key for a specific peer, or null if not authorized. */ +export async function getFileKey( + fileId: string, + peerPubkey: string, +): Promise { + const [row] = await db + .select({ sealedKey: meshFileKey.sealedKey }) + .from(meshFileKey) + .where( + and(eq(meshFileKey.fileId, fileId), eq(meshFileKey.peerPubkey, peerPubkey)), + ); + return row?.sealedKey ?? null; +} + +/** Grant a peer access to an encrypted file (upsert their key blob). */ +export async function grantFileKey( + fileId: string, + peerPubkey: string, + sealedKey: string, + grantedByPubkey: string, +): Promise { + await db + .insert(meshFileKey) + .values({ fileId, peerPubkey, sealedKey, grantedByPubkey }) + .onConflictDoUpdate({ + target: [meshFileKey.fileId, meshFileKey.peerPubkey], + set: { sealedKey, grantedByPubkey, grantedAt: new Date() }, + }); +} + // --- Context sharing --- /** diff --git a/packages/db/migrations/0012_add-file-encryption.sql b/packages/db/migrations/0012_add-file-encryption.sql new file mode 100644 index 0000000..ee426c1 --- /dev/null +++ b/packages/db/migrations/0012_add-file-encryption.sql @@ -0,0 +1,13 @@ +ALTER TABLE "mesh"."file" ADD COLUMN "encrypted" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "mesh"."file" ADD COLUMN "owner_pubkey" text;--> statement-breakpoint +CREATE TABLE "mesh"."file_key" ( + "id" text PRIMARY KEY NOT NULL, + "file_id" text NOT NULL, + "peer_pubkey" text NOT NULL, + "sealed_key" text NOT NULL, + "granted_at" timestamp DEFAULT now() NOT NULL, + "granted_by_pubkey" text +); +--> statement-breakpoint +ALTER TABLE "mesh"."file_key" ADD CONSTRAINT "file_key_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "mesh"."file"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "file_key_file_peer_idx" ON "mesh"."file_key" ("file_id","peer_pubkey"); diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index 33380e2..9583bd7 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -305,6 +305,8 @@ export const meshFile = meshSchema.table("file", { minioKey: text().notNull(), tags: text().array().default([]), persistent: boolean().notNull().default(true), + encrypted: boolean().notNull().default(false), + ownerPubkey: text(), uploadedByName: text(), uploadedByMember: text().references(() => meshMember.id), targetSpec: text(), // null = entire mesh @@ -327,6 +329,29 @@ export const meshFileAccess = meshSchema.table("file_access", { accessedAt: timestamp().defaultNow().notNull(), }); +/** + * Per-peer encrypted symmetric keys for E2E encrypted files. + * The file body is encrypted with a random key (Kf); Kf is sealed + * (crypto_box_seal) to each authorized peer's X25519 pubkey and stored here. + */ +export const meshFileKey = meshSchema.table("file_key", { + id: text().primaryKey().notNull().$defaultFn(generateId), + fileId: text() + .references(() => meshFile.id, { onDelete: "cascade" }) + .notNull(), + peerPubkey: text().notNull(), + sealedKey: text().notNull(), + grantedAt: timestamp().defaultNow().notNull(), + grantedByPubkey: text(), +}); + +export const meshFileKeyRelations = relations(meshFileKey, ({ one }) => ({ + file: one(meshFile, { + fields: [meshFileKey.fileId], + references: [meshFile.id], + }), +})); + /** * Per-peer context snapshot. Each peer (presence) has at most one context * entry per mesh, upserted on each share_context call. Allows peers to @@ -531,6 +556,10 @@ export type SelectMeshFile = typeof meshFile.$inferSelect; export type InsertMeshFile = typeof meshFile.$inferInsert; export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect; export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert; +export const selectMeshFileKeySchema = createSelectSchema(meshFileKey); +export const insertMeshFileKeySchema = createInsertSchema(meshFileKey); +export type SelectMeshFileKey = typeof meshFileKey.$inferSelect; +export type InsertMeshFileKey = typeof meshFileKey.$inferInsert; export const selectMeshContextSchema = createSelectSchema(meshContext); export const insertMeshContextSchema = createInsertSchema(meshContext); export const selectMeshTaskSchema = createSelectSchema(meshTask);