- Protocol 1 (docs/hub-protocol.md): public key, register, signed heartbeats - Ed25519 licence tokens (30-day validity, status, expiry, grace, limits) - Dashboard: installs, install detail, customers and licences, alerts, settings and audit log; two-factor login required - Alert checks every 5 minutes, emailed once; heartbeat roll-up after 30 days - Unit tests for encryption, tokens, signatures, TOTP and alert rules Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
136 lines
6.4 KiB
JavaScript
136 lines
6.4 KiB
JavaScript
jest.mock('../src/config/db', () => ({}));
|
|
|
|
const cryptoNode = require('crypto');
|
|
process.env.HUB_ENCRYPTION_KEY = 'test-encryption-key-that-is-long-enough-123';
|
|
|
|
const hubCrypto = require('../src/utils/crypto');
|
|
const licenceToken = require('../src/utils/licenceToken');
|
|
const { signature, checkSignature } = require('../src/utils/signing');
|
|
const totp = require('../src/utils/totp');
|
|
const { problems, compareVersions } = require('../src/utils/alerts');
|
|
|
|
describe('secrets at rest', () => {
|
|
test('encrypt/decrypt round-trip, different every time', () => {
|
|
const a = hubCrypto.encrypt('s3cret');
|
|
expect(a).not.toBe(hubCrypto.encrypt('s3cret'));
|
|
expect(hubCrypto.decrypt(a)).toBe('s3cret');
|
|
});
|
|
|
|
test('tampering is detected', () => {
|
|
const [v, iv, tag, data] = hubCrypto.encrypt('s3cret').split(':');
|
|
const flipped = Buffer.from(data, 'base64url');
|
|
flipped[0] ^= 1;
|
|
expect(() => hubCrypto.decrypt([v, iv, tag, flipped.toString('base64url')].join(':'))).toThrow();
|
|
});
|
|
|
|
test('licence keys look like CEH-XXXX-XXXX-XXXX-XXXX with no look-alike characters', () => {
|
|
const key = hubCrypto.licenceKey();
|
|
expect(key).toMatch(/^CEH(-[ACDEFGHJKMNPQRTUVWXY234679]{4}){4}$/);
|
|
expect(hubCrypto.normaliseLicenceKey(' ceh-7k2m-9qxa-p4rt-wd8c ')).toBe('CEH-7K2M-9QXA-P4RT-WD8C');
|
|
});
|
|
});
|
|
|
|
describe('licence tokens', () => {
|
|
const { privateKey, publicKey } = cryptoNode.generateKeyPairSync('ed25519');
|
|
const licence = { id: 'lic_1', plan: 'standard', status: 'active', expiresAt: new Date('2027-10-31T00:00:00Z'), graceDays: 14, aiMonthlyCapCents: 5000 };
|
|
const now = new Date('2026-10-24T08:00:00Z');
|
|
|
|
test('payload is valid for 30 days and carries the limits', () => {
|
|
const p = licenceToken.buildPayload(licence, { id: 'inst_1' }, 'Hope Church', now);
|
|
expect(p).toMatchObject({ v: 1, licenceId: 'lic_1', installId: 'inst_1', customer: 'Hope Church', status: 'active', graceDays: 14, limits: { aiMonthlyCapCents: 5000 } });
|
|
expect(p.validUntil).toBe('2026-11-23T08:00:00.000Z');
|
|
expect(p.licenceExpiresAt).toBe('2027-10-31T00:00:00.000Z');
|
|
});
|
|
|
|
test('signed tokens verify, edited ones do not', () => {
|
|
const token = licenceToken.sign({ status: 'active' }, privateKey);
|
|
expect(token.startsWith('hel1.')).toBe(true);
|
|
expect(licenceToken.verify(token, publicKey)).toEqual({ status: 'active' });
|
|
|
|
const [prefix, , sig] = token.split('.');
|
|
const forged = `${prefix}.${Buffer.from(JSON.stringify({ status: 'active', plan: 'gold' })).toString('base64url')}.${sig}`;
|
|
expect(licenceToken.verify(forged, publicKey)).toBeNull();
|
|
expect(licenceToken.verify('nonsense', publicKey)).toBeNull();
|
|
const other = cryptoNode.generateKeyPairSync('ed25519');
|
|
expect(licenceToken.verify(token, other.publicKey)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('request signatures', () => {
|
|
const secret = 'install-secret';
|
|
const now = Date.parse('2026-10-24T08:00:00Z');
|
|
const ts = '2026-10-24T08:00:30.000Z';
|
|
const body = '{"protocolVersion":1}';
|
|
|
|
test('a correct signature passes', () => {
|
|
expect(checkSignature({ secret, timestamp: ts, rawBody: body, given: signature(secret, ts, body), now })).toBeNull();
|
|
});
|
|
|
|
test('a changed body, wrong secret or old timestamp fails', () => {
|
|
expect(checkSignature({ secret, timestamp: ts, rawBody: body + ' ', given: signature(secret, ts, body), now })).toBe('BAD_SIGNATURE');
|
|
expect(checkSignature({ secret: 'other', timestamp: ts, rawBody: body, given: signature(secret, ts, body), now })).toBe('BAD_SIGNATURE');
|
|
const old = '2026-10-24T07:50:00.000Z';
|
|
expect(checkSignature({ secret, timestamp: old, rawBody: body, given: signature(secret, old, body), now })).toBe('STALE_REQUEST');
|
|
expect(checkSignature({ secret, timestamp: 'nope', rawBody: body, given: 'x', now })).toBe('STALE_REQUEST');
|
|
});
|
|
});
|
|
|
|
describe('two-factor codes', () => {
|
|
// RFC 6238 test secret "12345678901234567890" (SHA-1): at T=59s the 8-digit code is 94287082.
|
|
const secret = totp.base32Encode(Buffer.from('12345678901234567890'));
|
|
|
|
test('matches the RFC test vector', () => {
|
|
expect(totp.codeAt(secret, 1)).toBe('287082');
|
|
expect(totp.verifyCode(secret, '287082', 59 * 1000)).toBe(true);
|
|
});
|
|
|
|
test('accepts one step of clock drift, not more', () => {
|
|
expect(totp.verifyCode(secret, '287082', 89 * 1000)).toBe(true);
|
|
expect(totp.verifyCode(secret, '287082', 150 * 1000)).toBe(false);
|
|
expect(totp.verifyCode(secret, '12345', 59 * 1000)).toBe(false);
|
|
});
|
|
|
|
test('base32 round-trips', () => {
|
|
const buf = cryptoNode.randomBytes(20);
|
|
expect(totp.base32Decode(totp.base32Encode(buf)).equals(buf)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('alerts', () => {
|
|
const settings = { offline_minutes: '60', backup_max_age_hours: '48', disk_min_free_percent: '10', errors_per_day: '50', licence_expiry_warning_days: '14', latest_version: '1.42.0' };
|
|
const now = new Date('2026-10-24T12:00:00Z');
|
|
const healthy = {
|
|
registeredAt: new Date('2026-01-01'),
|
|
lastHeartbeatAt: new Date('2026-10-24T11:50:00Z'),
|
|
version: '1.42.0',
|
|
health: { db: true, lastBackupAt: '2026-10-24T02:00:00Z', disk: { freeBytes: 50, totalBytes: 100 }, errorsLast24h: 0, jobs: { failedLast24h: 0 }, pendingMigrations: 0 },
|
|
licence: { status: 'active', expiresAt: new Date('2027-10-31'), graceDays: 14 },
|
|
};
|
|
|
|
test('a healthy install has no problems', () => {
|
|
expect(problems(healthy, settings, now)).toEqual([]);
|
|
});
|
|
|
|
test('offline, stale backup, low disk, errors, expiring licence, behind', () => {
|
|
const types = problems({
|
|
...healthy,
|
|
lastHeartbeatAt: new Date('2026-10-24T10:00:00Z'),
|
|
version: '1.41.3',
|
|
health: { ...healthy.health, lastBackupAt: '2026-10-21T02:00:00Z', disk: { freeBytes: 5, totalBytes: 100 }, errorsLast24h: 80 },
|
|
licence: { status: 'active', expiresAt: new Date('2026-11-01'), graceDays: 14 },
|
|
}, settings, now).map(p => p.type);
|
|
expect(types).toEqual(['offline', 'backup_stale', 'disk_low', 'errors_high', 'licence_expiring', 'behind']);
|
|
});
|
|
|
|
test('a never-seen install is offline an hour after registering, with no health alerts', () => {
|
|
const types = problems({ registeredAt: new Date('2026-10-24T10:00:00Z'), lastHeartbeatAt: null, health: null, licence: null }, settings, now).map(p => p.type);
|
|
expect(types).toEqual(['offline']);
|
|
});
|
|
|
|
test('version comparison', () => {
|
|
expect(compareVersions('1.40.2', '1.41.0')).toBeLessThan(0);
|
|
expect(compareVersions('1.41.0', '1.41.0')).toBe(0);
|
|
expect(compareVersions('2.0.0', '1.99.9')).toBeGreaterThan(0);
|
|
});
|
|
});
|