secretbox.js (3244B)
1 // src/lib/secretbox.js - encryption at rest for stored secrets 2 // 3 // Used for project trigger secrets and project variables. When 4 // secrets.encryption_key is configured, values are sealed with AES-256-GCM. 5 // When it is not, values are stored base64 encoded and clearly marked as 6 // plaintext, so that an operator can tell at a glance which rows are 7 // protected and a later key rollout can find what needs upgrading. 8 // 9 // Stored format is a dotted, self describing string: 10 // v1.<iv>.<tag>.<ciphertext> sealed, all parts base64 11 // plain.<value> not encrypted, value base64 12 // 13 // The optional aad argument binds a ciphertext to its location, so a row 14 // copied into a different project or variable name fails to open. 15 16 import crypto from 'node:crypto'; 17 18 const IV_BYTES = 12; 19 20 export function createSecretBox(key) { 21 const enabled = Boolean(key); 22 if (enabled && (!Buffer.isBuffer(key) || key.length !== 32)) { 23 throw new Error('secret box key must be a 32 byte Buffer'); 24 } 25 26 return { 27 enabled, 28 29 seal(plaintext, aad) { 30 if (typeof plaintext !== 'string') throw new Error('secret value must be a string'); 31 if (!enabled) return `plain.${Buffer.from(plaintext, 'utf8').toString('base64')}`; 32 33 const iv = crypto.randomBytes(IV_BYTES); 34 const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); 35 if (aad) cipher.setAAD(Buffer.from(aad, 'utf8')); 36 const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); 37 const tag = cipher.getAuthTag(); 38 return `v1.${iv.toString('base64')}.${tag.toString('base64')}.${ct.toString('base64')}`; 39 }, 40 41 open(stored, aad) { 42 if (typeof stored !== 'string' || stored.length === 0) { 43 throw new Error('stored secret is empty'); 44 } 45 46 if (stored.startsWith('plain.')) { 47 return Buffer.from(stored.slice(6), 'base64').toString('utf8'); 48 } 49 50 if (!stored.startsWith('v1.')) { 51 throw new Error('stored secret has an unrecognised format'); 52 } 53 if (!enabled) { 54 throw new Error( 55 'stored secret is encrypted but secrets.encryption_key is not configured; ' + 56 'set it to the key this value was sealed with' 57 ); 58 } 59 60 const parts = stored.split('.'); 61 if (parts.length !== 4) throw new Error('stored secret is malformed'); 62 const [, ivB64, tagB64, ctB64] = parts; 63 64 const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64')); 65 decipher.setAuthTag(Buffer.from(tagB64, 'base64')); 66 if (aad) decipher.setAAD(Buffer.from(aad, 'utf8')); 67 try { 68 return Buffer.concat([ 69 decipher.update(Buffer.from(ctB64, 'base64')), 70 decipher.final(), 71 ]).toString('utf8'); 72 } catch (e) { 73 throw new Error( 74 'stored secret failed to decrypt; the encryption key may have changed, ' + 75 'or the value was moved between rows', 76 { cause: e } 77 ); 78 } 79 }, 80 81 // True when a value is stored without encryption, so an admin endpoint 82 // can report what a key rollout would need to re-seal. 83 isPlaintext(stored) { 84 return typeof stored === 'string' && stored.startsWith('plain.'); 85 }, 86 }; 87 }