conductor

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

secretbox.test.js (4012B)


      1 // test/secretbox.test.js - encryption at rest for stored secrets
      2 
      3 import test from 'node:test';
      4 import assert from 'node:assert/strict';
      5 import crypto from 'node:crypto';
      6 import { createSecretBox } from '../src/lib/secretbox.js';
      7 import { newJobId, newTaskId, slugify, randomId, timeOrderedId, hashToken, safeEqualHex } from '../src/lib/ids.js';
      8 
      9 const KEY = crypto.randomBytes(32);
     10 
     11 test('seals and opens a value', () => {
     12   const box = createSecretBox(KEY);
     13   const sealed = box.seal('hunter2');
     14   assert.ok(sealed.startsWith('v1.'));
     15   assert.ok(!sealed.includes('hunter2'));
     16   assert.equal(box.open(sealed), 'hunter2');
     17 });
     18 
     19 test('sealing twice produces different ciphertext', () => {
     20   const box = createSecretBox(KEY);
     21   assert.notEqual(box.seal('same'), box.seal('same'));
     22 });
     23 
     24 test('without a key, values are stored as marked plaintext', () => {
     25   const box = createSecretBox(null);
     26   const stored = box.seal('visible');
     27   assert.ok(stored.startsWith('plain.'));
     28   assert.equal(box.open(stored), 'visible');
     29   assert.equal(box.isPlaintext(stored), true);
     30 });
     31 
     32 test('a keyed box can still read values written before a key existed', () => {
     33   const plain = createSecretBox(null).seal('legacy');
     34   assert.equal(createSecretBox(KEY).open(plain), 'legacy');
     35 });
     36 
     37 test('an unkeyed box refuses encrypted values with an actionable message', () => {
     38   const sealed = createSecretBox(KEY).seal('secret');
     39   assert.throws(() => createSecretBox(null).open(sealed), /encryption_key is not configured/);
     40 });
     41 
     42 test('the wrong key fails to open', () => {
     43   const sealed = createSecretBox(KEY).seal('secret');
     44   assert.throws(() => createSecretBox(crypto.randomBytes(32)).open(sealed), /failed to decrypt/);
     45 });
     46 
     47 test('tampering with the ciphertext is detected', () => {
     48   const box = createSecretBox(KEY);
     49   const parts = box.seal('secret').split('.');
     50   const body = Buffer.from(parts[3], 'base64');
     51   body[0] ^= 0xff;
     52   parts[3] = body.toString('base64');
     53   assert.throws(() => box.open(parts.join('.')), /failed to decrypt/);
     54 });
     55 
     56 test('associated data binds a secret to its location', () => {
     57   const box = createSecretBox(KEY);
     58   const sealed = box.seal('token', 'project:a/var:TOKEN');
     59   assert.equal(box.open(sealed, 'project:a/var:TOKEN'), 'token');
     60   assert.throws(() => box.open(sealed, 'project:b/var:TOKEN'), /failed to decrypt/);
     61 });
     62 
     63 test('a key of the wrong size is refused at construction', () => {
     64   assert.throws(() => createSecretBox(crypto.randomBytes(16)), /32 byte Buffer/);
     65 });
     66 
     67 test('task ids are opaque, and say nothing about their job', () => {
     68   const jobId = newJobId();
     69   const ids = Array.from({ length: 200 }, () => newTaskId());
     70 
     71   for (const id of ids) {
     72     // A worker holds one of these. It must not be able to read the job,
     73     // the project or the task name out of it.
     74     assert.match(id, /^[0-9a-hjkmnp-tv-z]{20}$/, `${id} should be a plain base32 id`);
     75     assert.ok(!id.includes(jobId), 'a task id must not embed its job id');
     76   }
     77 
     78   // No name means no natural key, so uniqueness has to come from the id.
     79   assert.equal(new Set(ids).size, ids.length, 'task ids must not collide');
     80 });
     81 
     82 test('time ordered ids sort by creation time', () => {
     83   const early = timeOrderedId(1000000000000);
     84   const late = timeOrderedId(1000000001000);
     85   assert.ok(early < late);
     86   assert.equal(early.length, 20);
     87 });
     88 
     89 test('random ids use the expected alphabet', () => {
     90   assert.match(randomId(32), /^[0-9abcdefghjkmnpqrstvwxyz]{32}$/);
     91 });
     92 
     93 test('slugify produces readable project ids and falls back when empty', () => {
     94   assert.equal(slugify('My Project!'), 'my-project');
     95   assert.equal(slugify('  --weird--  '), 'weird');
     96   assert.match(slugify('!!!'), /^[0-9a-z]{16}$/);
     97 });
     98 
     99 test('token hashing and constant time comparison', () => {
    100   const a = hashToken('token-value');
    101   assert.match(a, /^[0-9a-f]{64}$/);
    102   assert.equal(safeEqualHex(a, hashToken('token-value')), true);
    103   assert.equal(safeEqualHex(a, hashToken('other')), false);
    104   assert.equal(safeEqualHex(a, 'short'), false);
    105 });