Skip to content

Commit 90e79ec

Browse files
authored
feat: adds various secrets functions to encrypt, decrypt and load secrets into process and import.meta (#1)
Adds several new functions to handle the encrypting, decrypting, and loading of secrets into the both Node's `process.env` and Vite's `import.meta.env`, including: - `encrypt` — which takes and encrypts stringified secrets into a secure format - `decrypt` — which takes the encrypted version of those secrets and returns them back - `mergeSecrets` — which simply merges the secrets from the existing `process.env` or `import.meta.env` with the payload - `loadSecrets` — performs all of the above and loads them all Also adds test runner CI using Vitest and updates JavaScript CI to correctly run Biome.
1 parent 30bcbae commit 90e79ec

File tree

9 files changed

+416
-12
lines changed

9 files changed

+416
-12
lines changed

.github/workflows/js-ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,5 @@ jobs:
4242
with:
4343
version: 1.9.4
4444
- name: Biome Check
45-
run: biome ci --formatter-enabled=false --changed
45+
run: biome ci --formatter-enabled=false
4646

.github/workflows/tests.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- '**/*.ts'
9+
- '**/*.js'
10+
pull_request:
11+
branches:
12+
- main
13+
paths:
14+
- '**/*.ts'
15+
- '**/*.js'
16+
17+
jobs:
18+
test:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Checkout Repository
22+
uses: actions/checkout@v4
23+
- name: Install pnpm
24+
uses: pnpm/action-setup@v4
25+
with:
26+
version: 10.6.5
27+
run_install: true
28+
- name: Install Node.js
29+
uses: actions/setup-node@v4
30+
with:
31+
node-version: 22
32+
cache: 'pnpm'
33+
- name: Run Tests
34+
run: pnpm test

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"lint:ts": "tsc --noEmit --pretty",
1414
"format": "biome format --write .",
1515
"build": "tsup",
16-
"test": "vitest",
16+
"test": "vitest run",
1717
"prepublishOnly": "pnpm build",
1818
"prepare": "husky"
1919
},
@@ -30,6 +30,7 @@
3030
"packageManager": "[email protected]",
3131
"devDependencies": {
3232
"@biomejs/biome": "1.9.4",
33+
"@types/node": "^22.13.11",
3334
"husky": "^9.1.7",
3435
"lint-staged": "^15.5.0",
3536
"tsup": "^8.4.0",

pnpm-lock.yaml

Lines changed: 28 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface ImportMeta {
2+
// biome-ignore lint/suspicious/noExplicitAny: Environment variables are often strings, but can be any type
3+
env: Record<string, any>;
4+
}

src/secrets.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { type EnvObject, EnvTarget } from './types';
2+
import { base64ToUint8Array, uint8ArrayToBase64 } from './utils';
3+
4+
const PBKDF2_ROUNDS = process.env.GITOPS_SECRETS_PBKDF2_ROUNDS || 1000000;
5+
const PBKDF2_KEYLEN = 32;
6+
const PBKDF2_DIGEST = 'SHA-256';
7+
const ALGORITHM = 'AES-GCM';
8+
const AES_IV_BYTES = 12;
9+
const AES_SALT_BYTES = 8;
10+
const ENCODING = 'base64';
11+
const TEXT_ENCODING = 'utf8';
12+
13+
function masterKey() {
14+
if (!process.env.GITOPS_SECRETS_MASTER_KEY || process.env.GITOPS_SECRETS_MASTER_KEY.length < 16) {
15+
throw new Error(
16+
`The 'GITOPS_SECRETS_MASTER_KEY' environment variable must be set to a string of 16 characters or more`,
17+
);
18+
}
19+
20+
return process.env.GITOPS_SECRETS_MASTER_KEY;
21+
}
22+
23+
/**
24+
* Derive encryption key using the Web Crypto API's PBKDF2
25+
*
26+
* @param {string} masterKeyString - The master key string
27+
* @param {Uint8Array} salt - The salt for key derivation
28+
* @param {number} iterations - The number of iterations for key derivation
29+
* @returns {Promise<CryptoKey>} - The derived key
30+
*/
31+
async function deriveKey(
32+
masterKeyString: string,
33+
salt: Uint8Array,
34+
iterations: number = Number(PBKDF2_ROUNDS),
35+
): Promise<CryptoKey> {
36+
const masterKeyBuffer = new TextEncoder().encode(masterKeyString);
37+
const importedKey = await crypto.subtle.importKey('raw', masterKeyBuffer, { name: 'PBKDF2' }, false, ['deriveKey']);
38+
39+
return crypto.subtle.deriveKey(
40+
{
41+
name: 'PBKDF2',
42+
salt: salt,
43+
iterations: iterations,
44+
hash: PBKDF2_DIGEST,
45+
},
46+
importedKey,
47+
{ name: ALGORITHM, length: PBKDF2_KEYLEN * 8 },
48+
false,
49+
['encrypt', 'decrypt'],
50+
);
51+
}
52+
53+
/**
54+
* Encrypt secrets to a secure format
55+
*
56+
* @param {string} secrets - The data to encrypt
57+
* @returns {Promise<string>} - Encrypted data in format "base64:rounds:salt:iv:encryptedData"
58+
*/
59+
async function encrypt(secrets: string): Promise<string> {
60+
const salt = crypto.getRandomValues(new Uint8Array(AES_SALT_BYTES));
61+
const iv = crypto.getRandomValues(new Uint8Array(AES_IV_BYTES));
62+
const key = await deriveKey(masterKey(), salt);
63+
64+
const dataBuffer = new TextEncoder().encode(secrets);
65+
const encryptedBuffer = await crypto.subtle.encrypt(
66+
{
67+
name: ALGORITHM,
68+
iv: iv,
69+
},
70+
key,
71+
dataBuffer,
72+
);
73+
74+
const saltBase64 = uint8ArrayToBase64(salt);
75+
const ivBase64 = uint8ArrayToBase64(iv);
76+
const encryptedBase64 = uint8ArrayToBase64(new Uint8Array(encryptedBuffer));
77+
78+
return `${ENCODING}:${PBKDF2_ROUNDS}:${saltBase64}:${ivBase64}:${encryptedBase64}`;
79+
}
80+
81+
/**
82+
* Decrypt secrets from secure format
83+
*
84+
* @param {string} ciphertext - Data in format "base64:rounds:salt:iv:encryptedData"
85+
* @returns {Promise<string>} - Decrypted data
86+
*/
87+
async function decrypt(ciphertext: string): Promise<string> {
88+
const encodedData = ciphertext.startsWith(`${ENCODING}:`) ? ciphertext.substring(`${ENCODING}:`.length) : ciphertext;
89+
90+
const parts = encodedData.split(':');
91+
if (parts.length !== 4) {
92+
throw new Error(`Encrypted payload invalid. Expected 4 sections but got ${parts.length}`);
93+
}
94+
95+
const rounds = Number.parseInt(parts[0], 10);
96+
const salt = base64ToUint8Array(parts[1]);
97+
const iv = base64ToUint8Array(parts[2]);
98+
const encryptedContent = base64ToUint8Array(parts[3]);
99+
100+
try {
101+
const key = await deriveKey(masterKey(), salt, rounds);
102+
103+
const decryptedBuffer = await crypto.subtle.decrypt(
104+
{
105+
name: ALGORITHM,
106+
iv: iv,
107+
},
108+
key,
109+
encryptedContent,
110+
);
111+
112+
const decrypted = new TextDecoder(TEXT_ENCODING).decode(decryptedBuffer);
113+
return decrypted;
114+
} catch (error) {
115+
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : String(error)}`);
116+
}
117+
}
118+
119+
/**
120+
* Get the appropriate environment object based on the target
121+
*
122+
* @param {EnvTarget} target - The environment target
123+
* @returns {EnvObject} - The environment object
124+
*/
125+
function getEnvObject(target: EnvTarget): EnvObject {
126+
switch (target) {
127+
case EnvTarget.PROCESS:
128+
if (typeof process !== 'undefined' && process.env) {
129+
return process.env;
130+
}
131+
throw new Error('process.env is not available in this environment');
132+
case EnvTarget.IMPORT_META:
133+
if (typeof import.meta !== 'undefined' && import.meta.env) {
134+
return import.meta.env;
135+
}
136+
throw new Error('import.meta.env is not available in this environment');
137+
138+
default:
139+
throw new Error(`Unsupported environment target: ${target}`);
140+
}
141+
}
142+
143+
/**
144+
* Merge secrets payload into the specified environment
145+
*
146+
* @param {Record<string, string>} payload - The payload object containing secrets
147+
* @param {EnvTarget} target - The environment target to merge secrets into
148+
* @returns {EnvObject} - The environment object with merged secrets
149+
*/
150+
function mergeSecrets(payload: Record<string, string>, target: EnvTarget): EnvObject {
151+
const envObject = getEnvObject(target);
152+
153+
for (const [key, value] of Object.entries(payload)) {
154+
if (target === EnvTarget.PROCESS && typeof process !== 'undefined') {
155+
process.env[key] = value;
156+
} else if (target === EnvTarget.IMPORT_META) {
157+
import.meta.env[key] = value;
158+
}
159+
}
160+
161+
return envObject;
162+
}
163+
164+
/**
165+
* Load encrypted secrets, decrypt them, and merge into the specified environment
166+
*
167+
* @param {string} encryptedSecrets - The encrypted secrets string
168+
* @param {EnvTarget} target - The environment target to merge with
169+
* @returns {Promise<EnvObject>} - The environment with merged secrets
170+
*/
171+
async function loadSecrets(encryptedSecrets: string, target: EnvTarget = EnvTarget.PROCESS): Promise<EnvObject> {
172+
try {
173+
const decryptedJson = await decrypt(encryptedSecrets);
174+
const secretsPayload = JSON.parse(decryptedJson) as Record<string, string>;
175+
176+
return mergeSecrets(secretsPayload, target);
177+
} catch (error) {
178+
console.error('Failed to load secrets:', error);
179+
return getEnvObject(target);
180+
}
181+
}
182+
183+
export { encrypt, decrypt, mergeSecrets, loadSecrets };

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export enum EnvTarget {
2+
PROCESS = 'process',
3+
IMPORT_META = 'import.meta',
4+
}
5+
6+
type EnvObject = {
7+
[key: string]: string | boolean | number | undefined | null | object;
8+
};
9+
10+
export type { EnvObject };

0 commit comments

Comments
 (0)