kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import {
2 createCipheriv,
3 createDecipheriv,
4 createHash,
5 randomBytes,
6} from "node:crypto";
7import { HTTPException } from "hono/http-exception";
8
9const SECRET_PREFIX = "enc:v1:";
10const SECRET_ALGORITHM = "aes-256-gcm";
11const SECRET_IV_BYTES = 12;
12
13function getSecretEncryptionKey() {
14 const rawKey = process.env.NOTIFICATION_SECRET_ENCRYPTION_KEY?.trim();
15 if (!rawKey) {
16 return null;
17 }
18
19 return createHash("sha256").update(rawKey).digest();
20}
21
22function requireSecretEncryptionKey() {
23 const key = getSecretEncryptionKey();
24 if (!key) {
25 throw new HTTPException(500, {
26 message:
27 "NOTIFICATION_SECRET_ENCRYPTION_KEY is required to store encrypted notification secrets",
28 });
29 }
30
31 return key;
32}
33
34function encodePart(value: Buffer) {
35 return value.toString("base64url");
36}
37
38function decodePart(value: string) {
39 return Buffer.from(value, "base64url");
40}
41
42export function isEncryptedSecret(value: string | null | undefined): boolean {
43 return typeof value === "string" && value.startsWith(SECRET_PREFIX);
44}
45
46function isValidEncryptedSecret(value: string): boolean {
47 try {
48 decryptSecret(value);
49 return true;
50 } catch {
51 return false;
52 }
53}
54
55export function encryptSecret(
56 value: string | null | undefined,
57): string | null | undefined {
58 if (value === undefined || value === null) {
59 return value;
60 }
61
62 if (isEncryptedSecret(value) && isValidEncryptedSecret(value)) {
63 return value;
64 }
65
66 const iv = randomBytes(SECRET_IV_BYTES);
67 const cipher = createCipheriv(
68 SECRET_ALGORITHM,
69 requireSecretEncryptionKey(),
70 iv,
71 );
72 const encrypted = Buffer.concat([
73 cipher.update(value, "utf8"),
74 cipher.final(),
75 ]);
76 const authTag = cipher.getAuthTag();
77
78 return `${SECRET_PREFIX}${encodePart(iv)}.${encodePart(authTag)}.${encodePart(encrypted)}`;
79}
80
81export function decryptSecret(
82 value: string | null | undefined,
83): string | null | undefined {
84 if (value === undefined || value === null || !isEncryptedSecret(value)) {
85 return value;
86 }
87
88 const payload = value.slice(SECRET_PREFIX.length);
89 const [iv, authTag, encrypted] = payload.split(".");
90
91 if (!iv || !authTag || !encrypted) {
92 throw new HTTPException(500, {
93 message: "Invalid encrypted notification secret payload",
94 });
95 }
96
97 try {
98 const decipher = createDecipheriv(
99 SECRET_ALGORITHM,
100 requireSecretEncryptionKey(),
101 decodePart(iv),
102 );
103 decipher.setAuthTag(decodePart(authTag));
104
105 return Buffer.concat([
106 decipher.update(decodePart(encrypted)),
107 decipher.final(),
108 ]).toString("utf8");
109 } catch (error) {
110 if (error instanceof HTTPException) {
111 throw error;
112 }
113 throw new HTTPException(500, {
114 message: "Failed to decrypt notification secret",
115 });
116 }
117}