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 });