import { describe, it, expect } from 'vitest' import { PASSCODE_ALPHABET, PASSCODE_LENGTH, PASSCODE_ENTROPY_BITS, PasscodeTooWeakError, assertPasscodeStrength, constantTimeEqual, generatePasscode, hashPasscode, verifyPasscode, randomSaltHex, } from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs' const SALT = '000102030405060708090a0b0c0d0e0f' describe('the pinned strength', () => { it('is at least 128 bits', () => { expect(PASSCODE_ENTROPY_BITS).toBeGreaterThanOrEqual(128) }) it('states an entropy figure the alphabet and length actually produce', () => { // 32 symbols is 5 bits each, exactly, because 256 divides by 32. expect(PASSCODE_ALPHABET.length).toBe(32) expect(PASSCODE_ENTROPY_BITS).toBe(PASSCODE_LENGTH * 5) }) it('draws from an alphabet with no repeats and no ambiguous characters', () => { expect(new Set(PASSCODE_ALPHABET).size).toBe(PASSCODE_ALPHABET.length) for (const c of 'ILOU') expect(PASSCODE_ALPHABET).not.toContain(c) }) it('survives the case normalisation verifyPasscode applies, without folding', () => { // Task 1713 made verification case-insensitive. This pins WHY that costs no // entropy, rather than leaving a comment to assert it. // // The argument: generation emits exactly one case, so uppercasing is the // identity over the generated set, and the map from a generated passcode to // its normalised form is injective. Two distinct generated passcodes can // therefore never fold onto one string, and PASSCODE_ENTROPY_BITS still // counts what it says it counts. Normalisation widens accepted INPUT, not // the search space. // // If this ever goes red, the alphabet has grown a character whose case // folds, and the entropy figure above is overstated. for (const c of PASSCODE_ALPHABET) expect(c.toUpperCase()).toBe(c) expect(new Set([...PASSCODE_ALPHABET].map((c) => c.toUpperCase())).size).toBe( PASSCODE_ALPHABET.length, ) const generated = Array.from({ length: 200 }, () => generatePasscode()) for (const pc of generated) expect(pc.toUpperCase()).toBe(pc) // Compared against the count of DISTINCT originals, not against 200: this // asserts injectivity alone, and does not quietly also assert that 200 draws // never repeat (which is generatePasscode's claim, tested separately). expect(new Set(generated.map((pc) => pc.toUpperCase())).size).toBe(new Set(generated).size) expect(PASSCODE_ENTROPY_BITS).toBe(PASSCODE_LENGTH * 5) }) }) describe('generatePasscode', () => { it('mints a passcode at the pinned length, from the pinned alphabet', () => { const pc = generatePasscode() expect(pc).toHaveLength(PASSCODE_LENGTH) for (const c of pc) expect(PASSCODE_ALPHABET).toContain(c) }) it('does not repeat itself', () => { const seen = new Set(Array.from({ length: 50 }, () => generatePasscode())) expect(seen.size).toBe(50) }) it('mints what the strength check accepts', () => { expect(() => assertPasscodeStrength(generatePasscode())).not.toThrow() }) }) describe('assertPasscodeStrength', () => { it('refuses a six-digit number — the case the skill prose allowed', () => { expect(() => assertPasscodeStrength('123456')).toThrow(PasscodeTooWeakError) }) it('refuses with a named reason rather than a bare Error', () => { try { assertPasscodeStrength('123456') throw new Error('should have refused') } catch (e) { expect(e).toBeInstanceOf(PasscodeTooWeakError) expect((e as PasscodeTooWeakError).reason).toBe('passcode-too-weak') } }) it('refuses one character short', () => { expect(() => assertPasscodeStrength('A'.repeat(PASSCODE_LENGTH - 1))).toThrow( PasscodeTooWeakError, ) }) it('refuses a character outside the alphabet', () => { expect(() => assertPasscodeStrength('!'.repeat(PASSCODE_LENGTH))).toThrow(PasscodeTooWeakError) }) it('refuses the ambiguous characters the alphabet excludes', () => { expect(() => assertPasscodeStrength('I'.repeat(PASSCODE_LENGTH))).toThrow(PasscodeTooWeakError) }) it('refuses an empty passcode', () => { expect(() => assertPasscodeStrength('')).toThrow(PasscodeTooWeakError) }) it('checks structure only, and does NOT claim to measure entropy', () => { // This documents a limitation deliberately. A repeated character has ~no // entropy yet passes, because length and alphabet are all a check on an // arbitrary string can verify. Entropy comes from generatePasscode alone. // If this ever throws, the check has grown a claim it cannot support. expect(() => assertPasscodeStrength('A'.repeat(PASSCODE_LENGTH))).not.toThrow() }) }) describe('hashPasscode', () => { it('is deterministic for the same passcode and salt', async () => { const pc = generatePasscode() expect(await hashPasscode(pc, SALT)).toBe(await hashPasscode(pc, SALT)) }) it('returns 64 hex characters', async () => { expect(await hashPasscode(generatePasscode(), SALT)).toMatch(/^[0-9a-f]{64}$/) }) it('differs for a different passcode', async () => { expect(await hashPasscode(generatePasscode(), SALT)).not.toBe( await hashPasscode(generatePasscode(), SALT), ) }) it('differs for the same passcode under a different salt', async () => { const pc = generatePasscode() const other = '0f0e0d0c0b0a09080706050403020100' expect(await hashPasscode(pc, SALT)).not.toBe(await hashPasscode(pc, other)) }) it('refuses to hash a weak passcode rather than hashing it anyway', async () => { // The mutation check: revert assertPasscodeStrength's call here and this // fails. Without it the check is decoration and the fast hash is unsafe. await expect(hashPasscode('123456', SALT)).rejects.toBeInstanceOf(PasscodeTooWeakError) }) it('rejects a salt that is not hex', async () => { await expect(hashPasscode(generatePasscode(), 'nothex')).rejects.toThrow() }) }) describe('verifyPasscode', () => { it('accepts the right passcode', async () => { const pc = generatePasscode() expect(await verifyPasscode(pc, SALT, await hashPasscode(pc, SALT))).toBe(true) }) it('rejects the wrong passcode', async () => { const stored = await hashPasscode(generatePasscode(), SALT) expect(await verifyPasscode(generatePasscode(), SALT, stored)).toBe(false) }) it('rejects rather than throws when the stored hash is malformed', async () => { expect(await verifyPasscode(generatePasscode(), SALT, 'nothex')).toBe(false) }) it('rejects a weak passcode at sign-in without throwing', async () => { // A row hashed before the check existed must not crash the auth route. expect(await verifyPasscode('123456', SALT, '0'.repeat(64))).toBe(false) }) it('accepts a lowercase transcription of the passcode', async () => { // The whole reason the alphabet excludes I, L, O and U is that a person // transcribes this by hand. Crockford is case-insensitive on decode for // that same reason. The mutation check: drop .toUpperCase() from // verifyPasscode and this goes red. const pc = generatePasscode() const stored = await hashPasscode(pc, SALT) expect(await verifyPasscode(pc.toLowerCase(), SALT, stored)).toBe(true) }) it('accepts a mixed-case transcription of the passcode', async () => { const pc = generatePasscode() const stored = await hashPasscode(pc, SALT) const mixed = [...pc].map((c, i) => (i % 2 ? c.toLowerCase() : c)).join('') expect(await verifyPasscode(mixed, SALT, stored)).toBe(true) }) it('still rejects the wrong passcode, lowercase included', async () => { // Widening the accepted case must not widen the accepted passcode. const stored = await hashPasscode(generatePasscode(), SALT) expect(await verifyPasscode(generatePasscode().toLowerCase(), SALT, stored)).toBe(false) }) it('returns false rather than throwing when the passcode is not a string', async () => { // .toUpperCase() throws on a non-string, so it sits INSIDE this function's // try. Move it out and this goes red. That placement is what keeps the // documented never-throw contract true for the input class it covers. // @ts-expect-error deliberately passing a non-string expect(await verifyPasscode(null, SALT, '0'.repeat(64))).toBe(false) }) }) describe('constantTimeEqual', () => { it('is true for identical arrays', () => { expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true) }) it('is false when a byte differs', () => { expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false) }) it('is false for different lengths, without throwing', () => { expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false) }) it('compares every byte even after a mismatch (no early return)', () => { // A first-byte mismatch and a last-byte mismatch must both be false. // This does not measure timing; it pins that the function does not // short-circuit in a way a rewrite could reintroduce. expect(constantTimeEqual(new Uint8Array([9, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(false) expect(constantTimeEqual(new Uint8Array([1, 2, 9]), new Uint8Array([1, 2, 3]))).toBe(false) }) }) describe('randomSaltHex', () => { it('renders bytes as lowercase hex, zero-padded', () => { expect(randomSaltHex(new Uint8Array([0, 15, 16, 255]))).toBe('000f10ff') }) })