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,
|
mesh,
|
||||||
meshFile,
|
meshFile,
|
||||||
meshFileAccess,
|
meshFileAccess,
|
||||||
|
meshFileKey,
|
||||||
meshContext,
|
meshContext,
|
||||||
meshMember as memberTable,
|
meshMember as memberTable,
|
||||||
meshMemory,
|
meshMemory,
|
||||||
@@ -717,6 +718,8 @@ export async function uploadFile(args: {
|
|||||||
uploadedByMember?: string;
|
uploadedByMember?: string;
|
||||||
targetSpec?: string;
|
targetSpec?: string;
|
||||||
expiresAt?: Date;
|
expiresAt?: Date;
|
||||||
|
encrypted?: boolean;
|
||||||
|
ownerPubkey?: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(meshFile)
|
.insert(meshFile)
|
||||||
@@ -732,6 +735,8 @@ export async function uploadFile(args: {
|
|||||||
uploadedByMember: args.uploadedByMember ?? null,
|
uploadedByMember: args.uploadedByMember ?? null,
|
||||||
targetSpec: args.targetSpec ?? null,
|
targetSpec: args.targetSpec ?? null,
|
||||||
expiresAt: args.expiresAt ?? null,
|
expiresAt: args.expiresAt ?? null,
|
||||||
|
encrypted: args.encrypted ?? false,
|
||||||
|
ownerPubkey: args.ownerPubkey ?? null,
|
||||||
})
|
})
|
||||||
.returning({ id: meshFile.id });
|
.returning({ id: meshFile.id });
|
||||||
if (!row) throw new Error("failed to insert file row");
|
if (!row) throw new Error("failed to insert file row");
|
||||||
@@ -755,6 +760,8 @@ export async function getFile(
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
targetSpec: string | null;
|
targetSpec: string | null;
|
||||||
uploadedAt: Date;
|
uploadedAt: Date;
|
||||||
|
encrypted: boolean;
|
||||||
|
ownerPubkey: string | null;
|
||||||
} | null> {
|
} | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -768,6 +775,8 @@ export async function getFile(
|
|||||||
uploadedByName: meshFile.uploadedByName,
|
uploadedByName: meshFile.uploadedByName,
|
||||||
targetSpec: meshFile.targetSpec,
|
targetSpec: meshFile.targetSpec,
|
||||||
uploadedAt: meshFile.uploadedAt,
|
uploadedAt: meshFile.uploadedAt,
|
||||||
|
encrypted: meshFile.encrypted,
|
||||||
|
ownerPubkey: meshFile.ownerPubkey,
|
||||||
})
|
})
|
||||||
.from(meshFile)
|
.from(meshFile)
|
||||||
.where(
|
.where(
|
||||||
@@ -782,6 +791,8 @@ export async function getFile(
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
tags: (row.tags ?? []) as string[],
|
tags: (row.tags ?? []) as string[],
|
||||||
|
encrypted: row.encrypted,
|
||||||
|
ownerPubkey: row.ownerPubkey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +812,7 @@ export async function listFiles(
|
|||||||
uploadedBy: string;
|
uploadedBy: string;
|
||||||
uploadedAt: Date;
|
uploadedAt: Date;
|
||||||
persistent: boolean;
|
persistent: boolean;
|
||||||
|
encrypted: boolean;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
@@ -822,6 +834,7 @@ export async function listFiles(
|
|||||||
uploadedByName: meshFile.uploadedByName,
|
uploadedByName: meshFile.uploadedByName,
|
||||||
uploadedAt: meshFile.uploadedAt,
|
uploadedAt: meshFile.uploadedAt,
|
||||||
persistent: meshFile.persistent,
|
persistent: meshFile.persistent,
|
||||||
|
encrypted: meshFile.encrypted,
|
||||||
})
|
})
|
||||||
.from(meshFile)
|
.from(meshFile)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
@@ -835,6 +848,7 @@ export async function listFiles(
|
|||||||
uploadedBy: r.uploadedByName ?? "unknown",
|
uploadedBy: r.uploadedByName ?? "unknown",
|
||||||
uploadedAt: r.uploadedAt,
|
uploadedAt: r.uploadedAt,
|
||||||
persistent: r.persistent,
|
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 ---
|
// --- 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(),
|
minioKey: text().notNull(),
|
||||||
tags: text().array().default([]),
|
tags: text().array().default([]),
|
||||||
persistent: boolean().notNull().default(true),
|
persistent: boolean().notNull().default(true),
|
||||||
|
encrypted: boolean().notNull().default(false),
|
||||||
|
ownerPubkey: text(),
|
||||||
uploadedByName: text(),
|
uploadedByName: text(),
|
||||||
uploadedByMember: text().references(() => meshMember.id),
|
uploadedByMember: text().references(() => meshMember.id),
|
||||||
targetSpec: text(), // null = entire mesh
|
targetSpec: text(), // null = entire mesh
|
||||||
@@ -327,6 +329,29 @@ export const meshFileAccess = meshSchema.table("file_access", {
|
|||||||
accessedAt: timestamp().defaultNow().notNull(),
|
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
|
* Per-peer context snapshot. Each peer (presence) has at most one context
|
||||||
* entry per mesh, upserted on each share_context call. Allows peers to
|
* 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 InsertMeshFile = typeof meshFile.$inferInsert;
|
||||||
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
|
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
|
||||||
export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert;
|
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 selectMeshContextSchema = createSelectSchema(meshContext);
|
||||||
export const insertMeshContextSchema = createInsertSchema(meshContext);
|
export const insertMeshContextSchema = createInsertSchema(meshContext);
|
||||||
export const selectMeshTaskSchema = createSelectSchema(meshTask);
|
export const selectMeshTaskSchema = createSelectSchema(meshTask);
|
||||||
|
|||||||
Reference in New Issue
Block a user