conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }