Repository Public
1import { describe, expect, it } from 'vitest';2import { seal, unseal, safeEqual } from './crypto.js';3import { hashPassword, hasArgon2, needsRehash, verifyPassword } from './password.js';4import { base32Decode, base32Encode, hotp, otpauthUrl, totpAt, totpCounter, verifyTotp } from './totp.js';5import { validateRel } from '../paths.js';6import { loadConfig } from '../config.js';7import { buildCsp, inlineScriptHashes } from '../static.js';8import { globToRegExp } from '../modules/tasks/artifacts.js';9import { buildClaudeArgs } from '../modules/sessions/index.js';10import { SESSION_TRANSITIONS, canTransition, canTaskTransition } from '@cr/shared';1112const cost = { argonMemoryKiB: 1024, argonPasses: 2, scryptLogN: 10 };1314describe('TOTP (RFC 6238 / RFC 4226)', () => {15 const secret = Buffer.from('12345678901234567890', 'ascii');16 // RFC 6238 Appendix B, SHA-1 rows (8 digits there; the low 6 digits are the 6-digit code).17 const vectors: [number, string][] = [18 [59, '94287082'], [1111111109, '07081804'], [1111111111, '14050471'],19 [1234567890, '89005924'], [2000000000, '69279037'], [20000000000, '65353130'],20 ];21 it.each(vectors)('t=%d s', (t, code8) => {22 expect(totpAt(secret, t * 1000, 8)).toBe(code8);23 expect(totpAt(secret, t * 1000)).toBe(code8.slice(2));24 });25 it('RFC 4226 HOTP vectors', () => {26 const expected = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];27 expected.forEach((code, i) => expect(hotp(secret, i)).toBe(code));28 });29 it('accepts ±1 step, rejects ±2, and refuses replays', () => {30 const now = 1_700_000_000_000;31 const c = totpCounter(now);32 expect(verifyTotp(secret, hotp(secret, c), now, null)).toBe(c);33 expect(verifyTotp(secret, hotp(secret, c - 1), now, null)).toBe(c - 1);34 expect(verifyTotp(secret, hotp(secret, c + 1), now, null)).toBe(c + 1);35 expect(verifyTotp(secret, hotp(secret, c - 2), now, null)).toBeNull();36 expect(verifyTotp(secret, hotp(secret, c + 2), now, null)).toBeNull();37 expect(verifyTotp(secret, hotp(secret, c), now, c)).toBeNull(); // same code again38 expect(verifyTotp(secret, hotp(secret, c - 1), now, c)).toBeNull(); // older code after a newer one39 expect(verifyTotp(secret, 'abcdef', now, null)).toBeNull();40 });41 it('base32 round-trips and matches RFC 4648', () => {42 expect(base32Encode(Buffer.from('foobar'))).toBe('MZXW6YTBOI');43 expect(base32Decode('MZXW6YTBOI').toString()).toBe('foobar');44 expect(base32Encode(secret)).toBe('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ');45 expect(otpauthUrl('ABC', 'admin')).toMatch(/^otpauth:\/\/totp\/Claude%20Remote:admin\?secret=ABC&issuer=Claude\+Remote/);46 });47});4849describe('password hashing', () => {50 it('hashes, verifies and reports parameters', async () => {51 const h = await hashPassword('a long enough password', cost);52 expect(h.startsWith(hasArgon2 ? '$argon2id$v=19$m=1024,t=2,p=1$' : '$scrypt$ln=10,r=8,p=1$')).toBe(true);53 expect(await verifyPassword('a long enough password', h)).toBe(true);54 expect(await verifyPassword('a long enough passwore', h)).toBe(false);55 expect(needsRehash(h, cost)).toBe(false);56 expect(needsRehash(h, { ...cost, argonMemoryKiB: 2048, scryptLogN: 11 })).toBe(true);57 });58 it('scrypt fallback verifies and malformed hashes never throw', async () => {59 const h = await hashPassword('another password 123', cost, true);60 expect(h.startsWith('$scrypt$ln=10,r=8,p=1$')).toBe(true);61 expect(await verifyPassword('another password 123', h)).toBe(true);62 expect(await verifyPassword('nope', h)).toBe(false);63 for (const bad of ['', 'plain', '$argon2id$v=19$m=x$a$b', '$scrypt$ln=99,r=8,p=1$aa$bb', '$md5$x$y$z']) {64 expect(await verifyPassword('x', bad)).toBe(false);65 }66 });67});6869describe('crypto helpers', () => {70 const key = Buffer.alloc(32, 7);71 it('seals with AES-256-GCM bound to its AAD', () => {72 const sealed = seal(key, 'JBSWY3DPEHPK3PXP', 'totp:u1');73 expect(sealed).not.toContain('JBSWY3DPEHPK3PXP');74 expect(unseal(key, sealed, 'totp:u1')).toBe('JBSWY3DPEHPK3PXP');75 expect(() => unseal(key, sealed, 'totp:u2')).toThrow();76 expect(() => unseal(Buffer.alloc(32, 8), sealed, 'totp:u1')).toThrow();77 const parts = sealed.split('.');78 parts[3] = `${parts[3]?.slice(0, -2)}AA`;79 expect(() => unseal(key, parts.join('.'), 'totp:u1')).toThrow();80 });81 it('safeEqual', () => {82 expect(safeEqual('abc', 'abc')).toBe(true);83 expect(safeEqual('abc', 'abd')).toBe(false);84 expect(safeEqual('abc', 'abcd')).toBe(false);85 });86});8788describe('path validation', () => {89 it.each(['../x', 'a/../../b', '/etc/passwd', 'a\0b', '..', 'a/..', '~/x', '\\\\srv\\x', 'C:\\x'])('rejects %j', (p) => {90 expect(() => validateRel(p)).toThrow(/invalid path/);91 });92 it('normalises clean paths', () => {93 expect(validateRel('a//b/./c/')).toBe('a/b/c');94 expect(validateRel('')).toBe('');95 expect(validateRel('..a/b..')).toBe('..a/b..');96 expect(() => validateRel('', { allowEmpty: false })).toThrow();97 });98});99100describe('configuration', () => {101 const prod = {102 NODE_ENV: 'production', CR_PUBLIC_ORIGIN: 'https://claude.example.org', DATABASE_URL: 'postgres://x/y',103 CR_RUNTIME_TOKEN: 'x'.repeat(40), CR_SECRET_KEY: 'ab'.repeat(32), CR_DATA_DIR: '/tmp/cr-none', CR_WORKSPACE_ROOTS: '/tmp/ws',104 };105 it('accepts a complete production configuration with secure cookies', () => {106 const c = loadConfig(prod);107 expect(c.cookieName).toBe('__Host-cr_sid');108 expect(c.secureCookies).toBe(true);109 expect(c.rpId).toBe('claude.example.org');110 expect(c.trustedProxies).toEqual(['127.0.0.1', '::1', '10.67.0.1']);111 });112 it.each([113 ['CR_SECRET_KEY', ''], ['CR_SECRET_KEY', 'abcd'], ['CR_RUNTIME_TOKEN', ''], ['DATABASE_URL', ''],114 ['CR_PUBLIC_ORIGIN', 'http://claude.example.org'], ['CR_PUBLIC_ORIGIN', 'https://claude.example.org/path'],115 ['CR_TRUSTED_PROXIES', 'not-an-ip'], ['CR_TRUSTED_PROXIES', '10.0.0.0/99'],116 ])('refuses production with %s=%j', (k, v) => {117 expect(() => loadConfig({ ...prod, [k]: v })).toThrow(/Invalid configuration/);118 });119 it('never downgrades cookies in production, and only for http://localhost otherwise', () => {120 expect(loadConfig({ ...prod, NODE_ENV: 'development', CR_PUBLIC_ORIGIN: 'http://localhost:8620' }).cookieName).toBe('cr_sid');121 expect(loadConfig({ ...prod, NODE_ENV: 'development', CR_PUBLIC_ORIGIN: 'http://example.org' }).cookieName).toBe('__Host-cr_sid');122 expect(() => loadConfig({ ...prod, CR_PUBLIC_ORIGIN: 'http://localhost:8620' })).toThrow();123 });124});125126describe('CSP', () => {127 const c = loadConfig({ NODE_ENV: 'test', CR_PUBLIC_ORIGIN: 'https://claude.example.org', CR_SECRET_KEY: 'ab'.repeat(32), CR_DATA_DIR: '/tmp/cr-none', CR_WORKSPACE_ROOTS: '/tmp/ws' });128 it('hashes inline scripts only', () => {129 const html = '<script src="/a.js"></script><script>alert(1)</script><script type="application/json">{"a":1}</script><script id="x">self.x=1</script>';130 const hashes = inlineScriptHashes(html);131 expect(hashes).toHaveLength(2);132 expect(hashes[0]).toBe("'sha256-bhHHL3z2vDgxUt0W3dWQOrprscmda2Y5pLsLg4GF+pI='"); // sha256("alert(1)")133 });134 it('never allows unsafe-inline scripts', () => {135 const csp = buildCsp(c, ["'sha256-abc='"]);136 const script = csp.split('; ').find((d) => d.startsWith('script-src')) ?? '';137 expect(script).toBe("script-src 'self' 'wasm-unsafe-eval' 'sha256-abc='");138 expect(script).not.toContain('unsafe-inline');139 expect(csp).toContain("frame-ancestors 'none'");140 expect(csp).toContain("connect-src 'self' wss://claude.example.org");141 expect(csp).toContain("object-src 'none'");142 // Same-origin PDF preview only: no third-party frame source, plugins stay forbidden.143 expect(csp.split('; ').find((d) => d.startsWith('frame-src'))).toBe("frame-src 'self' blob:");144 expect(csp.split('; ').filter((d) => d.startsWith('frame-') || d.startsWith('object-'))).toEqual(["frame-src 'self' blob:", "frame-ancestors 'none'", "object-src 'none'"]);145 });146});147148describe('small pure helpers', () => {149 it('globToRegExp', () => {150 expect(globToRegExp('*.txt').test('a.txt')).toBe(true);151 expect(globToRegExp('*.txt').test('d/a.txt')).toBe(false);152 expect(globToRegExp('out/**/*.csv').test('out/x/y/a.csv')).toBe(true);153 expect(globToRegExp('out/**/*.csv').test('out/a.csv')).toBe(true);154 expect(globToRegExp('a.b').test('axb')).toBe(false);155 });156 it('claude args come only from validated fields', () => {157 expect(buildClaudeArgs('claude', {})).toEqual([]);158 expect(buildClaudeArgs('claude', { continueLast: true })).toEqual(['--continue']);159 expect(buildClaudeArgs('claude', { resumeConversationId: 'abcd-1234-ef' })).toEqual(['--resume', 'abcd-1234-ef']);160 for (const bad of ['--dangerously-skip-permissions', 'short', 'a b c d e f g h', 'abcdefgh;rm', '-rf-abcdefgh x']) {161 expect(() => buildClaudeArgs('claude', { resumeConversationId: bad })).toThrow();162 }163 expect(() => buildClaudeArgs('claude', { resumeConversationId: 'abcd-1234-ef', continueLast: true })).toThrow();164 expect(() => buildClaudeArgs('shell', { continueLast: true })).toThrow();165 });166 it('state tables', () => {167 expect(canTransition('CREATED', 'STARTING')).toBe(true);168 expect(canTransition('STOPPED', 'RUNNING')).toBe(false);169 expect(canTransition('STARTING', 'RECOVERABLE')).toBe(false);170 expect(canTransition('RECOVERABLE', 'STARTING')).toBe(true);171 expect(SESSION_TRANSITIONS.FAILED).toEqual([]);172 expect(canTaskTransition('QUEUED', 'SUCCEEDED')).toBe(false);173 expect(canTaskTransition('RUNNING', 'LOST')).toBe(true);174 });175});176