feat: node tables

This commit is contained in:
Anthony Potdevin 2026-04-02 14:28:13 -06:00
parent e3f882b329
commit 129d094156
No known key found for this signature in database
GPG key ID: 4403F1DFBE779457
16 changed files with 942 additions and 1 deletions

1
.env
View file

@ -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

View file

@ -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;

View file

@ -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": {}
}
}

View file

@ -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
}
]
}

View file

@ -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`);

View file

@ -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": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1775074036636,
"tag": "0000_premium_pixie",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1775160646270,
"tag": "0001_fixed_nightshade",
"breakpoints": true
}
]
}

View file

@ -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,
};

View file

@ -1,2 +1,4 @@
export { teams } from './teams';
export { users } from './users';
export { nodes } from './nodes';
export { userNodes } from './user-nodes';

View file

@ -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(),
});

View file

@ -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)]
);

View file

@ -1,2 +1,4 @@
export { teams } from './teams';
export { users } from './users';
export { nodes } from './nodes';
export { userNodes } from './user-nodes';

View file

@ -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'))`),
});

View file

@ -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)]
);

View file

@ -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();
});
});
});

View file

@ -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:<b64>,tag:<b64>,data:<b64>]
*/
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<string, string> = {};
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');
}