Files
webmail/lib/__tests__/auth-crypto.test.ts
T

159 lines
6.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import {
encryptSession,
decryptSession,
encryptPayload,
decryptPayload,
} from '@/lib/auth/crypto';
// crypto.ts derives its key solely from getSessionSecret(); mock that one seam
// so we control the secret without touching configManager / env-file lookups.
const { secretRef } = vi.hoisted(() => ({ secretRef: { value: 'x'.repeat(32) } }));
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => secretRef.value,
hasSessionSecret: () => secretRef.value.length > 0,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
const SECRET = 'x'.repeat(32);
beforeEach(() => {
secretRef.value = SECRET;
});
describe('encryptSession / decryptSession', () => {
it('round-trips a session', () => {
const token = encryptSession('https://mail.example.com', 'alice', 's3cret');
expect(decryptSession(token)).toEqual({
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 's3cret',
signedInAt: expect.any(Number),
});
});
it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => {
const a = encryptSession('https://x', 'u', 'p', 1000);
const b = encryptSession('https://x', 'u', 'p', 1000);
expect(a).not.toBe(b);
expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64
expect(decryptSession(a)).toEqual(decryptSession(b));
});
it('returns null (not throw) on a tampered auth tag', () => {
const token = encryptSession('https://x', 'u', 'p');
const buf = Buffer.from(token, 'base64');
buf[13] ^= 0xff; // flip a byte inside the GCM tag region (bytes 12..28)
expect(decryptSession(buf.toString('base64'))).toBeNull();
});
it('returns null on a token shorter than IV+TAG', () => {
expect(decryptSession(Buffer.alloc(10).toString('base64'))).toBeNull();
});
it('returns null when the version is not 1', () => {
const token = encryptPayload({ v: 2, serverUrl: 'https://x', username: 'u', password: 'p' }, 'session-context');
expect(decryptSession(token)).toBeNull();
});
it('returns null when a required field is missing', () => {
const token = encryptPayload({ v: 1, serverUrl: 'https://x', username: 'u' }, 'session-context');
expect(decryptSession(token)).toBeNull();
});
it('throws when no secret is configured', () => {
secretRef.value = '';
expect(() => encryptSession('https://x', 'u', 'p')).toThrow('SESSION_SECRET not configured');
});
it('throws when the secret is shorter than 32 characters', () => {
secretRef.value = 'tooshort';
expect(() => encryptSession('https://x', 'u', 'p')).toThrow(/at least 32 characters/);
});
});
describe('encryptPayload / decryptPayload', () => {
it('round-trips an arbitrary object and stamps the purpose', () => {
const token = encryptPayload({ a: 1, b: 'two', c: { nested: true } }, 'sso-pending');
expect(decryptPayload(token, 'sso-pending')).toEqual({
a: 1, b: 'two', c: { nested: true }, p: 'sso-pending',
});
});
it('does NOT enforce the version/field guard that decryptSession applies', () => {
// CHARACTERISATION: decryptPayload returns whatever JSON parsed, with no
// v===1 / required-field validation (unlike decryptSession). The purpose
// is the only thing it enforces.
const token = encryptPayload({ v: 2, anything: 'goes' }, 'sso-pending');
expect(decryptPayload(token, 'sso-pending')).toEqual({ v: 2, anything: 'goes', p: 'sso-pending' });
});
it('returns null on a tampered token', () => {
const token = encryptPayload({ a: 1 }, 'session-context');
const buf = Buffer.from(token, 'base64');
buf[20] ^= 0xff;
expect(decryptPayload(buf.toString('base64'), 'session-context')).toBeNull();
});
it('refuses a blob minted for another purpose', () => {
// GHSA-cqqx-mjcf-mh55: an office editor token is authentic under the same
// key as the session cookie, so only the bound purpose separates them.
const editorToken = encryptPayload(
{ serverUrl: 'https://mail.example.com', username: 'alice', authHeader: 'Basic abc', fileId: 'd' },
'wopi-token',
);
expect(decryptPayload(editorToken, 'wopi-token')).not.toBeNull();
expect(decryptPayload(editorToken, 'session-context')).toBeNull();
expect(decryptPayload(editorToken, 'pair-reauth')).toBeNull();
expect(decryptPayload(editorToken, 'sso-pending')).toBeNull();
});
it('refuses a session cookie presented on any other path', () => {
const session = encryptPayload(
{ serverUrl: 'https://mail.example.com', username: 'alice', authHeader: 'Basic abc' },
'session-context',
);
expect(decryptPayload(session, 'wopi-token')).toBeNull();
expect(decryptPayload(session, 'pair-reauth')).toBeNull();
});
describe('payloads minted before purposes were bound', () => {
// Blobs from <= 1.9.3 carry no AAD. They are still opened on the path that
// minted them - otherwise upgrading logs everyone out - but only there.
function mintLegacy(payload: Record<string, unknown>): string {
const key = createHash('sha256').update(SECRET).digest();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64');
}
it('still opens a legacy session context', () => {
const legacy = mintLegacy({ serverUrl: 'https://mail.example.com', username: 'alice', authHeader: 'Basic abc' });
expect(decryptPayload(legacy, 'session-context')).toMatchObject({ username: 'alice' });
});
it('still opens a legacy pairing proof and SSO pending state', () => {
expect(decryptPayload(mintLegacy({ purpose: 'pair', created_at: 1 }), 'pair-reauth')).toMatchObject({ purpose: 'pair' });
expect(decryptPayload(mintLegacy({ state: 's', code_verifier: 'v' }), 'sso-pending')).toMatchObject({ state: 's' });
expect(decryptPayload(mintLegacy({ state: 's', purpose: 'reauth' }), 'sso-pending')).toMatchObject({ purpose: 'reauth' });
});
it('does not let a legacy editor token in through the session path', () => {
const legacyWopi = mintLegacy({
v: 1, t: 'wopi', serverUrl: 'https://mail.example.com', username: 'alice',
authHeader: 'Basic abc', fileId: 'd', exp: Date.now() + 1000,
});
expect(decryptPayload(legacyWopi, 'session-context')).toBeNull();
expect(decryptPayload(legacyWopi, 'wopi-token')).toMatchObject({ t: 'wopi' });
});
it('does not let a legacy pairing proof in through the session path', () => {
expect(decryptPayload(mintLegacy({ purpose: 'pair', created_at: 1 }), 'session-context')).toBeNull();
});
});
});