diff --git a/packages/auth/hasher.ts b/packages/auth/hasher.ts new file mode 100644 index 0000000..a4845e3 --- /dev/null +++ b/packages/auth/hasher.ts @@ -0,0 +1,89 @@ +export interface PassswordHasher { + hash(password: string): Promise + verify(password: string, hash: string): Promise +} + +export class BunSha256Hasher implements PassswordHasher { + async hash(password: string): Promise { + const hasher = new Bun.CryptoHasher("sha256") + hasher.update(password) + return hasher.digest("base64url") + } + + async verify(password: string, hash: string): Promise { + const hasher = new Bun.CryptoHasher("sha256") + hasher.update(password) + + const passwordHash = hasher.digest() + const hashBytes = Buffer.from(hash, "base64url") + + if (passwordHash.byteLength !== hashBytes.byteLength) { + return false + } + + return crypto.timingSafeEqual(passwordHash, hashBytes) + } +} + +export class WebCryptoSha256Hasher implements PassswordHasher { + async hash(password: string): Promise { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(password), + ) + return this.arrayBufferToBase64url(hash) + } + + async verify(password: string, hash: string): Promise { + const passwordHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(password), + ) + const hashBytes = this.base64urlToArrayBuffer(hash) + + if (passwordHash.byteLength !== hashBytes.byteLength) { + return false + } + + // Timing-safe comparison + const passwordHashBytes = new Uint8Array(passwordHash) + let result = 0 + for (let i = 0; i < passwordHashBytes.length; i++) { + result |= passwordHashBytes[i]! ^ hashBytes[i]! + } + return result === 0 + } + + private arrayBufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = "" + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!) + } + return btoa(binary).replace(/[+/=]/g, (char) => { + switch (char) { + case "+": return "-" + case "/": return "_" + case "=": return "" + default: return char + } + }) + } + + private base64urlToArrayBuffer(base64url: string): Uint8Array { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") + + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + "=", + ) + + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + + return bytes + } +} diff --git a/packages/auth/index.ts b/packages/auth/index.ts index c3d23ef..38381ae 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -1,3 +1,7 @@ +import { BunSha256Hasher, type PassswordHasher } from "./hasher" + +export { WebCryptoSha256Hasher } from "./hasher" + /** * An unhashed api key. * Always starts with sk, then the prefix specified at time of generation, @@ -7,6 +11,11 @@ export type UnhashedApiKey = `sk-${ApiKeyPrefix}-${string}` export type ApiKeyPrefix = string & { __brand: "ApiKeyPrefix" } +export type ParsedApiKey = { + prefix: ApiKeyPrefix + unhashedKey: UnhashedApiKey +} + export type GenerateApiKeyOptions = { /** * How long the key should be (excluding prefix) in bytes. @@ -21,6 +30,8 @@ export type GenerateApiKeyOptions = { expiresAt?: Date description: string + + hasher?: PassswordHasher } export type GenerateApiKeyResult = { @@ -30,11 +41,21 @@ export type GenerateApiKeyResult = { description: string } +export type VerifyApiKeyOptions = { + keyToBeVerified: UnhashedApiKey + hashedKey: string + hasher?: PassswordHasher +} + +function validatePrefix(prefix: string): prefix is ApiKeyPrefix { + return !prefix.includes("-") +} + export function newPrefix(prefix: string): ApiKeyPrefix | null { - if (prefix.includes("-")) { + if (!validatePrefix(prefix)) { return null } - return prefix as ApiKeyPrefix + return prefix } export async function generateApiKey({ @@ -42,6 +63,7 @@ export async function generateApiKey({ prefix, expiresAt, description, + hasher = new BunSha256Hasher(), }: GenerateApiKeyOptions): Promise { const keyContent = new Uint8Array(keyByteLength) crypto.getRandomValues(keyContent) @@ -49,11 +71,7 @@ export async function generateApiKey({ const base64KeyContent = Buffer.from(keyContent).toString("base64url") const unhashedKey: UnhashedApiKey = `sk-${prefix}-${base64KeyContent}` - const hashedKey = await Bun.password.hash(unhashedKey, { - algorithm: "argon2id", - memoryCost: 4, // memory usage in kibibytes - timeCost: 3, // the number of iterations - }) + const hashedKey = await hasher.hash(unhashedKey) return { unhashedKey, @@ -62,3 +80,29 @@ export async function generateApiKey({ description, } } + +export function parseApiKey(key: string): ParsedApiKey | null { + if (!key.startsWith("sk-")) { + return null + } + const parts = key.split("-") + if (parts.length !== 3) { + return null + } + const prefix = parts[1] + if (!prefix || !validatePrefix(prefix)) { + return null + } + return { + prefix, + unhashedKey: key as UnhashedApiKey, + } +} + +export async function verifyApiKey({ + keyToBeVerified, + hashedKey, + hasher = new BunSha256Hasher(), +}: VerifyApiKeyOptions): Promise { + return await hasher.verify(keyToBeVerified, hashedKey) +}