feat(broker/db): e2e file encryption schema + db functions
- add mesh.file_key table (fileId, peerPubkey, sealedKey, grantedByPubkey) - add encrypted + ownerPubkey columns to mesh.file - export insertFileKeys, getFileKey, grantFileKey from broker.ts - update uploadFile/getFile/listFiles to include encrypted/ownerPubkey - migration 0012_add-file-encryption applied to prod Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
await db
|
||||
.insert(meshFileKey)
|
||||
.values({ fileId, peerPubkey, sealedKey, grantedByPubkey })
|
||||
.onConflictDoUpdate({
|
||||
target: [meshFileKey.fileId, meshFileKey.peerPubkey],
|
||||
set: { sealedKey, grantedByPubkey, grantedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Context sharing ---
|
||||
|
||||
/**
|
||||
|
||||
13
packages/db/migrations/0012_add-file-encryption.sql
Normal file
13
packages/db/migrations/0012_add-file-encryption.sql
Normal file
@@ -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");
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user