From a1c77592bb4c95689b02d73904ce062bec503d82 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 22 Feb 2026 11:06:36 -0500 Subject: [PATCH 1/3] feat(core): add `safeEquals` for constant-time comparison across runtimes --- packages/core/CHANGELOG.md | 4 ++++ packages/core/package.json | 1 - packages/core/src/assert.ts | 13 +++++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 59582ed5..78a80fc3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Introduced `safeEquals` function for constant-time string comparison across runtimes. [#99](https://github.com/aura-stack-ts/auth/pull/99) + --- ## [0.4.0] - 2026-02-16 diff --git a/packages/core/package.json b/packages/core/package.json index 1c44dffa..80aa8fea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,7 +64,6 @@ "devDependencies": { "@aura-stack/tsconfig": "workspace:*", "@aura-stack/tsup-config": "workspace:*", - "@types/node": "catalog:node", "typescript": "catalog:typescript" } } diff --git a/packages/core/src/assert.ts b/packages/core/src/assert.ts index 94275820..6d84d2ce 100644 --- a/packages/core/src/assert.ts +++ b/packages/core/src/assert.ts @@ -1,5 +1,5 @@ import { equals } from "@/utils.js" -import { timingSafeEqual } from "crypto" +import { encoder } from "@aura-stack/jose/crypto" import type { JWTPayloadWithToken } from "@/@types/index.js" export const isFalsy = (value: unknown): boolean => { @@ -124,10 +124,15 @@ export const isTrustedOrigin = (url: string, trustedOrigins: string[]): boolean } export const safeEquals = (a: string, b: string): boolean => { - const bufferA = Buffer.from(a) - const bufferB = Buffer.from(b) + const bufferA = encoder.encode(a) + const bufferB = encoder.encode(b) if (bufferA.length !== bufferB.length) { return false } - return timingSafeEqual(bufferA, bufferB) + for (let i = 0; i < bufferA.length; i++) { + if (bufferA[i] ^ bufferB[i]) { + return false + } + } + return true } From f85f69f13b1d4eb304c6b6d2bb4d01d41c5b1c53 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 22 Feb 2026 11:09:23 -0500 Subject: [PATCH 2/3] chore(core): rename from `safeEquals` to `timingSafeEqual` --- packages/core/CHANGELOG.md | 2 +- packages/core/src/actions/callback/callback.ts | 4 ++-- packages/core/src/assert.ts | 2 +- packages/core/src/secure.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 78a80fc3..d1621ed3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- Introduced `safeEquals` function for constant-time string comparison across runtimes. [#99](https://github.com/aura-stack-ts/auth/pull/99) +- Introduced `timingSafeEqual` function for constant-time string comparison across runtimes. [#99](https://github.com/aura-stack-ts/auth/pull/99) --- diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index b63dfae6..38366f78 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { createEndpoint, createEndpointConfig, HeadersBuilder } from "@aura-stack/router" import { createCSRF } from "@/secure.js" import { cacheControl } from "@/headers.js" -import { isRelativeURL, isSameOrigin, isTrustedOrigin, safeEquals } from "@/assert.js" +import { isRelativeURL, isSameOrigin, isTrustedOrigin, timingSafeEqual } from "@/assert.js" import { getUserInfo } from "@/actions/callback/userinfo.js" import { OAuthAuthorizationErrorResponse } from "@/schemas.js" import { AuthSecurityError, OAuthProtocolError } from "@/errors.js" @@ -71,7 +71,7 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { const cookieRedirectTo = getCookie(request, cookies.redirectTo.name) const cookieRedirectURI = getCookie(request, cookies.redirectURI.name) - if (!safeEquals(cookieState, state)) { + if (!timingSafeEqual(cookieState, state)) { logger?.log("MISMATCHING_STATE", { structuredData: { oauth_provider: oauth, diff --git a/packages/core/src/assert.ts b/packages/core/src/assert.ts index 6d84d2ce..59bfee7c 100644 --- a/packages/core/src/assert.ts +++ b/packages/core/src/assert.ts @@ -123,7 +123,7 @@ export const isTrustedOrigin = (url: string, trustedOrigins: string[]): boolean return false } -export const safeEquals = (a: string, b: string): boolean => { +export const timingSafeEqual = (a: string, b: string): boolean => { const bufferA = encoder.encode(a) const bufferB = encoder.encode(b) if (bufferA.length !== bufferB.length) { diff --git a/packages/core/src/secure.ts b/packages/core/src/secure.ts index caab52ae..0c41ad88 100644 --- a/packages/core/src/secure.ts +++ b/packages/core/src/secure.ts @@ -1,6 +1,6 @@ import { equals } from "@/utils.js" import { AuthSecurityError } from "@/errors.js" -import { isJWTPayloadWithToken, safeEquals } from "@/assert.js" +import { isJWTPayloadWithToken, timingSafeEqual } from "@/assert.js" import { jwtVerificationOptions, base64url, encoder, getRandomBytes, getSubtleCrypto } from "@/jose.js" import type { AuthRuntimeConfig } from "@/@types/index.js" @@ -69,7 +69,7 @@ export const verifyCSRF = async (jose: AuthRuntimeConfig["jose"], cookie: string if (!equals(cookiePayload.token.length, headerPayload.token.length)) { throw new AuthSecurityError("CSRF_TOKEN_INVALID", "The CSRF tokens do not match.") } - if (!safeEquals(cookiePayload.token, headerPayload.token)) { + if (!timingSafeEqual(cookiePayload.token, headerPayload.token)) { throw new AuthSecurityError("CSRF_TOKEN_INVALID", "The CSRF tokens do not match.") } return true From accd964fcaae7d43172270c61c9f6731ac920839 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 22 Feb 2026 11:29:55 -0500 Subject: [PATCH 3/3] refactor(core): update timingSafeEqual for constant-time verification --- bun.lock | 1 - packages/core/src/assert.ts | 11 +++++------ pnpm-lock.yaml | 3 --- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index e47eacca..2ecb5f8e 100644 --- a/bun.lock +++ b/bun.lock @@ -265,7 +265,6 @@ "devDependencies": { "@aura-stack/tsconfig": "workspace:*", "@aura-stack/tsup-config": "workspace:*", - "@types/node": "catalog:node", "typescript": "catalog:typescript", }, }, diff --git a/packages/core/src/assert.ts b/packages/core/src/assert.ts index 59bfee7c..4b81b3a0 100644 --- a/packages/core/src/assert.ts +++ b/packages/core/src/assert.ts @@ -126,13 +126,12 @@ export const isTrustedOrigin = (url: string, trustedOrigins: string[]): boolean export const timingSafeEqual = (a: string, b: string): boolean => { const bufferA = encoder.encode(a) const bufferB = encoder.encode(b) - if (bufferA.length !== bufferB.length) { + if(bufferA.length !== bufferB.length) { return false } - for (let i = 0; i < bufferA.length; i++) { - if (bufferA[i] ^ bufferB[i]) { - return false - } + let diff = 0 + for(let i = 0; i < bufferA.length; i++) { + diff |= bufferA[i] ^ bufferB[i] } - return true + return diff === 0 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c863fa58..5e7d0eff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,9 +583,6 @@ importers: '@aura-stack/tsup-config': specifier: workspace:* version: link:../../configs/tsup-config - '@types/node': - specifier: catalog:node - version: 24.10.13 typescript: specifier: catalog:typescript version: 5.9.3