57 lines
1.5 KiB
TypeScript
57 lines
1.5 KiB
TypeScript
'use node';
|
|
|
|
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
createHash,
|
|
randomBytes,
|
|
} from 'node:crypto';
|
|
import { ConvexError } from 'convex/values';
|
|
|
|
const getSecret = () => {
|
|
const secret =
|
|
process.env.SPOON_ENCRYPTION_KEY?.trim() ??
|
|
process.env.INSTANCE_SECRET?.trim();
|
|
if (!secret) {
|
|
throw new ConvexError(
|
|
'SPOON_ENCRYPTION_KEY is not configured. Add it before storing user API keys.',
|
|
);
|
|
}
|
|
return secret;
|
|
};
|
|
|
|
const getKey = () => createHash('sha256').update(getSecret()).digest();
|
|
|
|
export const encryptSecret = (plaintext: string) => {
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
|
|
const ciphertext = Buffer.concat([
|
|
cipher.update(plaintext, 'utf8'),
|
|
cipher.final(),
|
|
]);
|
|
const tag = cipher.getAuthTag();
|
|
return [
|
|
iv.toString('base64url'),
|
|
tag.toString('base64url'),
|
|
ciphertext.toString('base64url'),
|
|
].join('.');
|
|
};
|
|
|
|
export const decryptSecret = (encrypted: string) => {
|
|
const [ivRaw, tagRaw, ciphertextRaw] = encrypted.split('.');
|
|
if (!ivRaw || !tagRaw || !ciphertextRaw) {
|
|
throw new ConvexError('Stored secret has an invalid format.');
|
|
}
|
|
const decipher = createDecipheriv(
|
|
'aes-256-gcm',
|
|
getKey(),
|
|
Buffer.from(ivRaw, 'base64url'),
|
|
);
|
|
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
|
|
const plaintext = Buffer.concat([
|
|
decipher.update(Buffer.from(ciphertextRaw, 'base64url')),
|
|
decipher.final(),
|
|
]);
|
|
return plaintext.toString('utf8');
|
|
};
|