UNPKG

@withstudiocms/auth-kit

Version:

Utilities for managing authentication

92 lines (91 loc) 3.22 kB
import { sha1 } from "@oslojs/crypto/sha1"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { Effect, Platform } from "@withstudiocms/effect"; import { PasswordError, usePasswordError } from "../errors.js"; import { CheckIfUnsafe } from "./unsafeCheck.js"; const constantTimeEqual = (a, b) => { const len = Math.max(a.length, b.length); let result = a.length ^ b.length; for (let i = 0; i < len; i++) { const ca = i < a.length ? a.charCodeAt(i) : 0; const cb = i < b.length ? b.charCodeAt(i) : 0; result |= ca ^ cb; } return result === 0; }; const PASS_GEN1_0_PREFIX = "gen1.0"; const buildSecurePassword = Effect.fn( ({ generation, hash, salt }) => Effect.succeed(`${generation}:${salt}:${hash}`) ); const breakSecurePassword = Effect.fn( (hash) => usePasswordError(() => { const parts = hash.split(":"); if (parts.length !== 3) { throw new PasswordError({ cause: 'Invalid secure password format. Expected "gen1.0:salt:hash".' }); } const [generation, salt, hashValue] = parts; if (generation !== PASS_GEN1_0_PREFIX) { throw new PasswordError({ cause: "Legacy password hashes are not supported. Please reset any legacy passwords." }); } if (!salt || !hashValue) { throw new PasswordError({ cause: "Invalid secure password format: missing salt or hash." }); } return { generation, salt, hash: hashValue }; }) ); const verifyPasswordLength = Effect.fn( (pass) => usePasswordError(() => { if (pass.length < 6 || pass.length > 255) { return "Password must be between 6 and 255 characters long."; } return void 0; }) ); const verifySafe = (pass) => Effect.gen(function* () { const check = yield* CheckIfUnsafe; const isUnsafe = yield* check.password(pass); if (isUnsafe) { return "Password must not be a commonly known unsafe password (admin, root, etc.)"; } return void 0; }).pipe(Effect.provide(CheckIfUnsafe.Default)); const checkPwnedDB = (pass) => Effect.gen(function* () { const http = yield* Platform.HttpClient.HttpClient; const encodedData = new TextEncoder().encode(pass); const sha1Hash = sha1(encodedData); const hashHex = encodeHexLowerCase(sha1Hash); const hashPrefix = hashHex.slice(0, 5); const response = yield* http.get(`https://api.pwnedpasswords.com/range/${hashPrefix}`).pipe( Effect.catchTags({ RequestError: () => Effect.succeed({ text: Effect.succeed(""), status: 500 }), ResponseError: () => Effect.succeed({ text: Effect.succeed(""), status: 500 }) }) ); if (response.status >= 400) { return void 0; } const data = yield* response.text; const lines = data.split("\n"); for (const line of lines) { const hashSuffix = line.slice(0, 35).toLowerCase(); if (hashHex === hashPrefix + hashSuffix) { return 'Password must not be in the <a href="https://haveibeenpwned.com/Passwords" target="_blank">pwned password database</a>.'; } } return void 0; }).pipe(Effect.provide(Platform.FetchHttpClient.layer)); export { PASS_GEN1_0_PREFIX, breakSecurePassword, buildSecurePassword, checkPwnedDB, constantTimeEqual, verifyPasswordLength, verifySafe };