diff --git a/.env b/.env index 14152299..e6a0cd22 100644 --- a/.env +++ b/.env @@ -71,6 +71,7 @@ # DB_TYPE='sqlite' # 'sqlite' or 'postgres' (omit to disable database) # DB_SQLITE_PATH='/path/to/thunderhub.db' # Required when DB_TYPE is 'sqlite' # DB_POSTGRES_URL='postgres://user:pass@localhost:5432/thunderhub' # Required when DB_TYPE is 'postgres' +# DB_ENCRYPTION_KEY='' # 64-char hex string (32 bytes) for AES-256-GCM encryption of node credentials at rest. Generate with: openssl rand -hex 32 # ----------- # SSL Config diff --git a/drizzle/pg/0001_jazzy_ezekiel.sql b/drizzle/pg/0001_jazzy_ezekiel.sql new file mode 100644 index 00000000..6415ea87 --- /dev/null +++ b/drizzle/pg/0001_jazzy_ezekiel.sql @@ -0,0 +1,25 @@ +CREATE TABLE "nodes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "team_id" uuid NOT NULL, + "name" text NOT NULL, + "type" text NOT NULL, + "network" text NOT NULL, + "socket" text NOT NULL, + "encrypted_macaroon" text, + "encrypted_cert" text, + "created_at" timestamp(6) DEFAULT now() NOT NULL, + "updated_at" timestamp(6) DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_nodes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "node_id" uuid NOT NULL, + "created_at" timestamp(6) DEFAULT now() NOT NULL, + "updated_at" timestamp(6) DEFAULT now() NOT NULL, + CONSTRAINT "user_nodes_user_id_node_id_unique" UNIQUE("user_id","node_id") +); +--> statement-breakpoint +ALTER TABLE "nodes" ADD CONSTRAINT "nodes_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_nodes" ADD CONSTRAINT "user_nodes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_nodes" ADD CONSTRAINT "user_nodes_node_id_nodes_id_fk" FOREIGN KEY ("node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/pg/meta/0001_snapshot.json b/drizzle/pg/meta/0001_snapshot.json new file mode 100644 index 00000000..bf544711 --- /dev/null +++ b/drizzle/pg/meta/0001_snapshot.json @@ -0,0 +1,312 @@ +{ + "id": "db15d19a-e7aa-485a-aa86-83712b251358", + "prevId": "f2a5cebb-ac67-464a-bfc5-d0fa612c3552", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.nodes": { + "name": "nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "socket": { + "name": "socket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_macaroon": { + "name": "encrypted_macaroon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_cert": { + "name": "encrypted_cert", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "nodes_team_id_teams_id_fk": { + "name": "nodes_team_id_teams_id_fk", + "tableFrom": "nodes", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nodes": { + "name": "user_nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nodes_user_id_users_id_fk": { + "name": "user_nodes_user_id_users_id_fk", + "tableFrom": "user_nodes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_nodes_node_id_nodes_id_fk": { + "name": "user_nodes_node_id_nodes_id_fk", + "tableFrom": "user_nodes", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nodes_user_id_node_id_unique": { + "name": "user_nodes_user_id_node_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "node_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_team_id_teams_id_fk": { + "name": "users_team_id_teams_id_fk", + "tableFrom": "users", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/pg/meta/_journal.json b/drizzle/pg/meta/_journal.json index b8d81d98..b5276b3d 100644 --- a/drizzle/pg/meta/_journal.json +++ b/drizzle/pg/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775074089419, "tag": "0000_new_adam_warlock", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775160645885, + "tag": "0001_jazzy_ezekiel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0001_fixed_nightshade.sql b/drizzle/sqlite/0001_fixed_nightshade.sql new file mode 100644 index 00000000..7b0f37ba --- /dev/null +++ b/drizzle/sqlite/0001_fixed_nightshade.sql @@ -0,0 +1,25 @@ +CREATE TABLE `nodes` ( + `id` text PRIMARY KEY NOT NULL, + `team_id` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `network` text NOT NULL, + `socket` text NOT NULL, + `encrypted_macaroon` text, + `encrypted_cert` text, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (`team_id`) REFERENCES `teams`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `user_nodes` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `node_id` text NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`node_id`) REFERENCES `nodes`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_nodes_user_id_node_id_unique` ON `user_nodes` (`user_id`,`node_id`); \ No newline at end of file diff --git a/drizzle/sqlite/meta/0001_snapshot.json b/drizzle/sqlite/meta/0001_snapshot.json new file mode 100644 index 00000000..40e3aa14 --- /dev/null +++ b/drizzle/sqlite/meta/0001_snapshot.json @@ -0,0 +1,321 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d34e8a0e-f74d-4dc7-9408-1a37d9ee5df6", + "prevId": "296005fb-1644-4912-8e82-1ebb647066e2", + "tables": { + "nodes": { + "name": "nodes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "socket": { + "name": "socket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_macaroon": { + "name": "encrypted_macaroon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_cert": { + "name": "encrypted_cert", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "nodes_team_id_teams_id_fk": { + "name": "nodes_team_id_teams_id_fk", + "tableFrom": "nodes", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_nodes": { + "name": "user_nodes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "user_nodes_user_id_node_id_unique": { + "name": "user_nodes_user_id_node_id_unique", + "columns": [ + "user_id", + "node_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_nodes_user_id_users_id_fk": { + "name": "user_nodes_user_id_users_id_fk", + "tableFrom": "user_nodes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_nodes_node_id_nodes_id_fk": { + "name": "user_nodes_node_id_nodes_id_fk", + "tableFrom": "user_nodes", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": { + "users_team_id_teams_id_fk": { + "name": "users_team_id_teams_id_fk", + "tableFrom": "users", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index a12194cf..a7334abd 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775074036636, "tag": "0000_premium_pixie", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1775160646270, + "tag": "0001_fixed_nightshade", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/config/configuration.ts b/src/server/config/configuration.ts index 54aca28a..06ed6767 100644 --- a/src/server/config/configuration.ts +++ b/src/server/config/configuration.ts @@ -81,7 +81,10 @@ type PostgresConfig = { url?: string; }; -type DatabaseConfig = SqliteConfig | PostgresConfig | undefined; +type DatabaseConfig = + | (SqliteConfig & { encryptionKey?: string }) + | (PostgresConfig & { encryptionKey?: string }) + | undefined; type ConfigType = { basePath: string; @@ -244,10 +247,12 @@ export default (): ConfigType => { ? { type: 'postgres' as const, url: process.env.DB_POSTGRES_URL, + encryptionKey: process.env.DB_ENCRYPTION_KEY, } : { type: 'sqlite' as const, path: process.env.DB_SQLITE_PATH, + encryptionKey: process.env.DB_ENCRYPTION_KEY, } : undefined, }; diff --git a/src/server/modules/database/schema/pg/index.ts b/src/server/modules/database/schema/pg/index.ts index 568611a8..e53c1690 100644 --- a/src/server/modules/database/schema/pg/index.ts +++ b/src/server/modules/database/schema/pg/index.ts @@ -1,2 +1,4 @@ export { teams } from './teams'; export { users } from './users'; +export { nodes } from './nodes'; +export { userNodes } from './user-nodes'; diff --git a/src/server/modules/database/schema/pg/nodes.ts b/src/server/modules/database/schema/pg/nodes.ts new file mode 100644 index 00000000..4ecadb5d --- /dev/null +++ b/src/server/modules/database/schema/pg/nodes.ts @@ -0,0 +1,24 @@ +import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { teams } from './teams'; + +export const nodes = pgTable('nodes', { + id: uuid('id') + .primaryKey() + .default(sql`gen_random_uuid()`), + team_id: uuid('team_id') + .notNull() + .references(() => teams.id), + name: text('name').notNull(), + type: text('type').notNull(), + network: text('network').notNull(), + socket: text('socket').notNull(), + encrypted_macaroon: text('encrypted_macaroon'), + encrypted_cert: text('encrypted_cert'), + created_at: timestamp('created_at', { precision: 6, mode: 'string' }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { precision: 6, mode: 'string' }) + .notNull() + .defaultNow(), +}); diff --git a/src/server/modules/database/schema/pg/user-nodes.ts b/src/server/modules/database/schema/pg/user-nodes.ts new file mode 100644 index 00000000..90c26125 --- /dev/null +++ b/src/server/modules/database/schema/pg/user-nodes.ts @@ -0,0 +1,26 @@ +import { pgTable, uuid, timestamp, unique } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from './users'; +import { nodes } from './nodes'; + +export const userNodes = pgTable( + 'user_nodes', + { + id: uuid('id') + .primaryKey() + .default(sql`gen_random_uuid()`), + user_id: uuid('user_id') + .notNull() + .references(() => users.id), + node_id: uuid('node_id') + .notNull() + .references(() => nodes.id), + created_at: timestamp('created_at', { precision: 6, mode: 'string' }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { precision: 6, mode: 'string' }) + .notNull() + .defaultNow(), + }, + t => [unique().on(t.user_id, t.node_id)] +); diff --git a/src/server/modules/database/schema/sqlite/index.ts b/src/server/modules/database/schema/sqlite/index.ts index 568611a8..e53c1690 100644 --- a/src/server/modules/database/schema/sqlite/index.ts +++ b/src/server/modules/database/schema/sqlite/index.ts @@ -1,2 +1,4 @@ export { teams } from './teams'; export { users } from './users'; +export { nodes } from './nodes'; +export { userNodes } from './user-nodes'; diff --git a/src/server/modules/database/schema/sqlite/nodes.ts b/src/server/modules/database/schema/sqlite/nodes.ts new file mode 100644 index 00000000..8606987a --- /dev/null +++ b/src/server/modules/database/schema/sqlite/nodes.ts @@ -0,0 +1,24 @@ +import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; +import { teams } from './teams'; + +export const nodes = sqliteTable('nodes', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + team_id: text('team_id') + .notNull() + .references(() => teams.id), + name: text('name').notNull(), + type: text('type').notNull(), + network: text('network').notNull(), + socket: text('socket').notNull(), + encrypted_macaroon: text('encrypted_macaroon'), + encrypted_cert: text('encrypted_cert'), + created_at: text('created_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + updated_at: text('updated_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), +}); diff --git a/src/server/modules/database/schema/sqlite/user-nodes.ts b/src/server/modules/database/schema/sqlite/user-nodes.ts new file mode 100644 index 00000000..3ba7ab54 --- /dev/null +++ b/src/server/modules/database/schema/sqlite/user-nodes.ts @@ -0,0 +1,26 @@ +import { sqliteTable, text, unique } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; +import { users } from './users'; +import { nodes } from './nodes'; + +export const userNodes = sqliteTable( + 'user_nodes', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + user_id: text('user_id') + .notNull() + .references(() => users.id), + node_id: text('node_id') + .notNull() + .references(() => nodes.id), + created_at: text('created_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + updated_at: text('updated_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + }, + t => [unique().on(t.user_id, t.node_id)] +); diff --git a/src/server/utils/encryption/field-encryption.spec.ts b/src/server/utils/encryption/field-encryption.spec.ts new file mode 100644 index 00000000..ab49898f --- /dev/null +++ b/src/server/utils/encryption/field-encryption.spec.ts @@ -0,0 +1,63 @@ +import { randomBytes } from 'crypto'; +import { encryptValue, decryptValue } from './field-encryption'; + +const TEST_KEY = randomBytes(32).toString('hex'); + +describe('field-encryption', () => { + describe('encryptValue / decryptValue', () => { + it('should roundtrip a simple string', () => { + const plaintext = 'hello world'; + const encrypted = encryptValue(plaintext, TEST_KEY); + expect(encrypted).toMatch(/^ENC\[AES256_GCM,/); + expect(decryptValue(encrypted, TEST_KEY)).toBe(plaintext); + }); + + it('should roundtrip an empty string', () => { + const encrypted = encryptValue('', TEST_KEY); + expect(decryptValue(encrypted, TEST_KEY)).toBe(''); + }); + + it('should roundtrip a long hex string (macaroon-like)', () => { + const macaroon = randomBytes(256).toString('hex'); + const encrypted = encryptValue(macaroon, TEST_KEY); + expect(decryptValue(encrypted, TEST_KEY)).toBe(macaroon); + }); + + it('should produce different ciphertexts for the same plaintext (unique IV)', () => { + const plaintext = 'same input'; + const a = encryptValue(plaintext, TEST_KEY); + const b = encryptValue(plaintext, TEST_KEY); + expect(a).not.toBe(b); + expect(decryptValue(a, TEST_KEY)).toBe(plaintext); + expect(decryptValue(b, TEST_KEY)).toBe(plaintext); + }); + + it('should reject an invalid key length', () => { + expect(() => encryptValue('test', 'short')).toThrow( + 'Encryption key must be 32 bytes' + ); + expect(() => + decryptValue('ENC[AES256_GCM,iv:a,tag:b,data:c]', 'short') + ).toThrow('Encryption key must be 32 bytes'); + }); + + it('should reject malformed encrypted strings', () => { + expect(() => decryptValue('not-encrypted', TEST_KEY)).toThrow( + 'Invalid encrypted value format' + ); + }); + + it('should fail on tampered ciphertext (GCM auth tag check)', () => { + const encrypted = encryptValue('secret', TEST_KEY); + // Tamper with the data portion + const tampered = encrypted.replace(/data:[A-Za-z0-9+/=]+/, 'data:AAAA'); + expect(() => decryptValue(tampered, TEST_KEY)).toThrow(); + }); + + it('should fail with a wrong key', () => { + const encrypted = encryptValue('secret', TEST_KEY); + const wrongKey = randomBytes(32).toString('hex'); + expect(() => decryptValue(encrypted, wrongKey)).toThrow(); + }); + }); +}); diff --git a/src/server/utils/encryption/field-encryption.ts b/src/server/utils/encryption/field-encryption.ts new file mode 100644 index 00000000..de3628be --- /dev/null +++ b/src/server/utils/encryption/field-encryption.ts @@ -0,0 +1,71 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const PREFIX = 'ENC[AES256_GCM,'; + +/** + * Encrypts a single plaintext string using AES-256-GCM. + * Returns a SOPS-style string: ENC[AES256_GCM,iv:,tag:,data:] + */ +export function encryptValue(plaintext: string, keyHex: string): string { + const key = Buffer.from(keyHex, 'hex'); + if (key.length !== 32) { + throw new Error('Encryption key must be 32 bytes (64 hex characters)'); + } + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + + const tag = cipher.getAuthTag(); + + return `${PREFIX}iv:${iv.toString('base64')},tag:${tag.toString('base64')},data:${encrypted.toString('base64')}]`; +} + +/** + * Decrypts a SOPS-style ENC[AES256_GCM,...] string back to plaintext. + */ +export function decryptValue(encrypted: string, keyHex: string): string { + const key = Buffer.from(keyHex, 'hex'); + if (key.length !== 32) { + throw new Error('Encryption key must be 32 bytes (64 hex characters)'); + } + + if (!encrypted.startsWith(PREFIX) || !encrypted.endsWith(']')) { + throw new Error('Invalid encrypted value format'); + } + + const inner = encrypted.slice(PREFIX.length, -1); + const parts: Record = {}; + + for (const segment of inner.split(',')) { + const idx = segment.indexOf(':'); + if (idx === -1) throw new Error('Invalid encrypted value format'); + parts[segment.slice(0, idx)] = segment.slice(idx + 1); + } + + if ( + parts.iv === undefined || + parts.tag === undefined || + parts.data === undefined + ) { + throw new Error('Invalid encrypted value format: missing iv, tag, or data'); + } + + const iv = Buffer.from(parts.iv, 'base64'); + const tag = Buffer.from(parts.tag, 'base64'); + const ciphertext = Buffer.from(parts.data, 'base64'); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString('utf8'); +}