@zudojs/auth 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +41 -5
  2. package/dist/authErrors/authError.base.d.ts +6 -25
  3. package/dist/authErrors/authError.base.js +6 -20
  4. package/dist/authPassword/authPassword.core.d.ts +17 -29
  5. package/dist/authPassword/authPassword.core.js +33 -119
  6. package/dist/authPassword/authPassword.legacy.d.ts +21 -0
  7. package/dist/authPassword/authPassword.legacy.js +85 -0
  8. package/dist/authPassword/authPassword.policy.d.ts +30 -0
  9. package/dist/authPassword/authPassword.policy.js +30 -0
  10. package/dist/authPassword/authPassword.rehash.d.ts +15 -0
  11. package/dist/authPassword/authPassword.rehash.js +33 -0
  12. package/dist/authPassword/index.d.ts +3 -1
  13. package/dist/authPassword/index.js +3 -1
  14. package/dist/authProvider/authAttempt.eviction.d.ts +26 -0
  15. package/dist/authProvider/authAttempt.eviction.js +37 -0
  16. package/dist/authProvider/authAttempt.memory.d.ts +9 -0
  17. package/dist/authProvider/authAttempt.memory.js +22 -5
  18. package/dist/authProvider/authProvider.core.d.ts +13 -11
  19. package/dist/authProvider/authProvider.core.js +14 -60
  20. package/dist/authProvider/authProvider.throttle.d.ts +52 -0
  21. package/dist/authProvider/authProvider.throttle.js +88 -0
  22. package/dist/authSession/authSession.core.js +5 -4
  23. package/dist/authToken/authToken.core.d.ts +6 -0
  24. package/dist/authToken/authToken.core.js +8 -0
  25. package/dist/authToken/authToken.signing.d.ts +5 -0
  26. package/dist/authToken/authToken.signing.js +7 -2
  27. package/dist/authTypes/authAttempt.type.d.ts +13 -3
  28. package/dist/authUtils/authUtils.helper.d.ts +4 -0
  29. package/dist/authUtils/authUtils.helper.js +4 -0
  30. package/package.json +5 -4
package/README.md CHANGED
@@ -4,6 +4,12 @@ Authentication primitives: JWT access/refresh tokens, server-side sessions,
4
4
  scrypt password hashing, brute-force lockout, and RBAC delegation to
5
5
  [`@zudojs/permissions`](https://www.npmjs.com/package/@zudojs/permissions).
6
6
 
7
+ <!-- zudo-docs:start -->
8
+
9
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-auth](https://zudojs.oyinlola.site/docs/packages-auth) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-auth.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
10
+
11
+ <!-- zudo-docs:end -->
12
+
7
13
  ## Installation
8
14
 
9
15
  ```bash
@@ -86,7 +92,7 @@ const auth = createAuthService({
86
92
  store: createMemoryLoginAttemptStore({ windowSeconds: 60 }),
87
93
  maxFailedAttempts: 5, // -> AccountLockedError (423)
88
94
  lockoutSeconds: 900,
89
- maxAttemptsPerWindow: 20, // -> AuthRateLimitError (429)
95
+ maxAttemptsPerWindow: 20, // -> AuthRateLimitError (429), per identifier
90
96
  },
91
97
 
92
98
  // Optional: real permission matching. Without it, `checkAccess()` throws
@@ -114,8 +120,28 @@ await auth.logoutAll(user.id); // sign out everywhere
114
120
  `verifyToken()` and `refresh()` require that session to still exist, so
115
121
  `logout()` / `logoutAll()` invalidate outstanding access **and** refresh
116
122
  tokens immediately rather than leaving them live for their natural lifetime.
117
- Tokens minted with the standalone `createTokenPair()` carry no `sid` and are
118
- therefore not session-bound.
123
+ Tokens minted with the standalone `createTokenPair()` carry no `sid`, so no
124
+ logout can revoke them. **The service therefore rejects a token without a
125
+ `sid` (`TokenInvalidError`) unless you set `allowSessionlessTokens: true`.**
126
+ Accepting them by default let a session-less refresh chain outlive
127
+ `logoutAll()`.
128
+
129
+ ### Brute-force lockout
130
+
131
+ A failure is reserved *before* the password is checked and cleared on
132
+ success, so a parallel burst gets exactly `maxFailedAttempts` guesses before
133
+ the lockout, not `maxAttemptsPerWindow`. Custom `LoginAttemptStore`s must make
134
+ `recordFailure` atomic for this to hold.
135
+
136
+ The budgets are **per identifier**. An attacker rotating identifiers is not
137
+ limited by them, and every unknown identifier still costs one scrypt
138
+ verification, so put a per-IP limiter (`createRateLimiter` from
139
+ `@zudojs/security`) in front of `login()`.
140
+
141
+ `createMemoryLoginAttemptStore({ failureTtlSeconds, maxEntries })` forgets an
142
+ unlocked failure streak after `failureTtlSeconds` of inactivity (default 900)
143
+ and caps the tracked identifiers at `maxEntries` (default 100 000, oldest
144
+ unlocked evicted first), so spraying identifiers cannot grow it without bound.
119
145
 
120
146
  ### Refresh-token rotation
121
147
 
@@ -160,7 +186,9 @@ The `jwt` namespace bundles `createTokenPair`, `verifyAccessToken`,
160
186
  `jwt.refreshAccessToken()` is the **non-rotating** variant: it checks the
161
187
  signature, expiry and type and nothing else — no revocation store, no user
162
188
  re-load, no session check — so a stolen refresh token stays replayable for its
163
- full lifetime. Use `auth.refresh()` for anything user-facing.
189
+ full lifetime. Use `auth.refresh()` for anything user-facing. A refresh token
190
+ that carries a `sid` produces a pair with the same `sid`, so the new tokens
191
+ still die with that session when verified through the service.
164
192
 
165
193
  Tokens are capped at 8 KB and every segment is bounds-checked before it is
166
194
  decoded, so an oversized `Authorization` header is rejected without
@@ -171,11 +199,19 @@ allocating.
171
199
  ```typescript
172
200
  import { hashPassword, verifyPassword, needsRehash } from "@zudojs/auth";
173
201
 
174
- const hash = await hashPassword("plain-text-password"); // scrypt N=16384,r=8,p=1
202
+ const hash = await hashPassword("plain-text-password"); // "v1$scrypt$16384$8$5$…"
175
203
  const ok = await verifyPassword("plain-text-password", hash);
176
204
  if (needsRehash(hash)) { /* re-hash on next successful login */ }
177
205
  ```
178
206
 
207
+ Hashing is delegated to `@zudojs/crypto`: new hashes are its
208
+ `v1$scrypt$N$r$p$<salt>.<hash>` strings (Base64URL, 32-byte salt, 64-byte key)
209
+ with OWASP's N=2^14, r=8, p=5 row. Hashes written by earlier versions of this
210
+ package (`scrypt$N$r$p$…`, including the p=1 default and the param-less legacy
211
+ format) still verify, and `needsRehash()` returns `true` for every hash that is
212
+ not a current-parameter `@zudojs/crypto` scrypt hash, so they upgrade on the
213
+ next login. `hashPassword("")` throws `AuthError` (`INVALID_INPUT`).
214
+
179
215
  - `createAuthService()` validates its configuration up front: bad or
180
216
  identical secrets, a non-positive or `NaN` `sessionTtlSeconds` /
181
217
  `absoluteSessionTtlSeconds`, and a non-finite (`NaN`/`Infinity`)
@@ -8,33 +8,14 @@
8
8
  * name a user, a password, or an account state that the caller did not
9
9
  * already supply), so they are safe to return to a client verbatim.
10
10
  */
11
- import { BaseError, ErrorCode, ErrorCategory, ErrorSeverity, type ErrorMetadata } from "@zudojs/errors";
11
+ import { AuthError, type AuthErrorOptions } from "@zudojs/errors";
12
12
  /**
13
- * Options accepted by {@link AuthError} and every subclass.
14
- *
15
- * Subclasses supply sensible defaults for `code`, `category`, `statusCode`
16
- * and `expose`; anything passed here overrides them. Accepting the full set
17
- * also keeps `BaseError.withMetadata()` — which reconstructs the error from
18
- * its own fields — lossless for these classes.
13
+ * `AuthError` (the base of every class below) and `AuthErrorOptions` live
14
+ * in `@zudojs/errors`; they are re-exported here so existing imports keep
15
+ * working. Defaults: `401 Unauthorized`, category `authentication`,
16
+ * `expose: true`; every default can be overridden.
19
17
  */
20
- export interface AuthErrorOptions {
21
- readonly code?: ErrorCode;
22
- readonly category?: ErrorCategory;
23
- readonly severity?: ErrorSeverity;
24
- readonly statusCode?: number;
25
- readonly expose?: boolean;
26
- readonly isOperational?: boolean;
27
- readonly metadata?: ErrorMetadata;
28
- readonly cause?: unknown;
29
- }
30
- /**
31
- * Base error for all auth-related failures.
32
- *
33
- * Defaults to `401 Unauthorized`, category `authentication`, `expose: true`.
34
- */
35
- export declare class AuthError extends BaseError {
36
- constructor(message: string, options?: AuthErrorOptions);
37
- }
18
+ export { AuthError, type AuthErrorOptions };
38
19
  /**
39
20
  * The package is misconfigured (missing/weak signing secret, missing
40
21
  * permission engine, …). Not caused by the request, so `500` and not exposed.
@@ -8,28 +8,14 @@
8
8
  * name a user, a password, or an account state that the caller did not
9
9
  * already supply), so they are safe to return to a client verbatim.
10
10
  */
11
- import { BaseError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
11
+ import { AuthError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
12
12
  /**
13
- * Base error for all auth-related failures.
14
- *
15
- * Defaults to `401 Unauthorized`, category `authentication`, `expose: true`.
13
+ * `AuthError` (the base of every class below) and `AuthErrorOptions` live
14
+ * in `@zudojs/errors`; they are re-exported here so existing imports keep
15
+ * working. Defaults: `401 Unauthorized`, category `authentication`,
16
+ * `expose: true`; every default can be overridden.
16
17
  */
17
- export class AuthError extends BaseError {
18
- constructor(message, options) {
19
- super(message, {
20
- code: options?.code ?? ErrorCode.AUTHENTICATION,
21
- category: options?.category ?? ErrorCategory.AUTHENTICATION,
22
- severity: options?.severity ?? ErrorSeverity.ERROR,
23
- statusCode: options?.statusCode ?? 401,
24
- expose: options?.expose ?? true,
25
- ...(options?.isOperational !== undefined
26
- ? { isOperational: options.isOperational }
27
- : {}),
28
- metadata: options?.metadata,
29
- cause: options?.cause,
30
- });
31
- }
32
- }
18
+ export { AuthError };
33
19
  /**
34
20
  * The package is misconfigured (missing/weak signing secret, missing
35
21
  * permission engine, …). Not caused by the request, so `500` and not exposed.
@@ -1,41 +1,34 @@
1
1
  /**
2
- * Password hashing and verification using Node.js crypto scrypt.
2
+ * Password hashing and verification, delegated to `@zudojs/crypto`.
3
3
  *
4
4
  * @module authPassword/authPassword
5
5
  *
6
- * Uses scrypt with salt for secure password hashing.
7
- * Compatible with Node.js ≥ 24 (no external dependencies).
6
+ * New hashes are `@zudojs/crypto` scrypt hashes
7
+ * (`v1$scrypt$N$r$p$<salt>.<hash>`, Base64URL). Hashes written by earlier
8
+ * versions of this package (`scrypt$…`) still verify through the legacy
9
+ * verifier, and `needsRehash()` reports them so they upgrade on next login.
8
10
  */
9
- /** Accepted range for a caller-supplied salt length, in bytes. */
10
- export declare const MIN_SALT_LENGTH = 16;
11
- export declare const MAX_SALT_LENGTH = 64;
12
11
  /**
13
- * Maximum accepted password length in bytes.
12
+ * Hash a plain-text password with `@zudojs/crypto` (scrypt N=2^14, r=8,
13
+ * p=5, 64-byte key).
14
14
  *
15
- * scrypt's cost is set by N/r, not by the input length, so a long password
16
- * is not a work-factor amplifier — but it is still an unbounded allocation
17
- * driven by an unauthenticated request body. 1024 bytes is far past any
18
- * real passphrase.
19
- */
20
- export declare const MAX_PASSWORD_BYTES = 1024;
21
- /**
22
- * Hash a plain-text password.
23
- *
24
- * @param password - Plain-text password. Must be at most
15
+ * @param password - Plain-text password. Must be non-empty and at most
25
16
  * {@link MAX_PASSWORD_BYTES} bytes of UTF-8.
26
17
  * @param saltLength - Salt length in bytes (default: 32). Must be an integer
27
18
  * between {@link MIN_SALT_LENGTH} and {@link MAX_SALT_LENGTH}; `0` would
28
19
  * otherwise silently produce unsalted, rainbow-table-able hashes.
29
- * @returns Hashed password string in format "scrypt$N$r$p$salt$hash"
20
+ * @returns Hashed password string in the `@zudojs/crypto` format
21
+ * `v1$scrypt$N$r$p$<salt>.<hash>`
30
22
  * @throws {AuthError} with `ErrorCode.INVALID_INPUT` when the password is
31
- * not a string, is too long, or the salt length is out of range.
23
+ * not a non-empty string, is too long, or the salt length is out of range.
32
24
  */
33
25
  export declare function hashPassword(password: string, saltLength?: number): Promise<string>;
34
26
  /**
35
27
  * Verify a plain-text password against a hash.
36
28
  *
37
- * Accepts the current "scrypt$N$r$p$salt$hash" format as well as the
38
- * legacy "scrypt<salt>$<hash>" format produced by versions ≤ 0.1.1.
29
+ * `v1$…` hashes are verified by `@zudojs/crypto`. Hashes from earlier
30
+ * versions of this package ("scrypt$N$r$p$salt$hash" and the ≤ 0.1.1
31
+ * "scrypt<salt>$<hash>") go through the legacy verifier.
39
32
  *
40
33
  * Never throws: any input this function cannot make sense of — a
41
34
  * non-string, an over-length password (see {@link MAX_PASSWORD_BYTES}), an
@@ -48,17 +41,12 @@ export declare function hashPassword(password: string, saltLength?: number): Pro
48
41
  * @returns Whether the password matches
49
42
  */
50
43
  export declare function verifyPassword(password: string, hashedPassword: string): Promise<boolean>;
51
- /**
52
- * Check if a password hash needs rehashing (legacy format, changed
53
- * scrypt parameters, or changed salt length).
54
- *
55
- * @param hashedPassword - The stored hash
56
- * @returns Whether the hash should be regenerated
57
- */
58
- export declare function needsRehash(hashedPassword: string): boolean;
59
44
  /**
60
45
  * Generate a random token string (for password reset, etc.).
61
46
  *
47
+ * Synchronous by contract, so it draws from `node:crypto` directly: every
48
+ * `@zudojs/crypto` random helper is asynchronous.
49
+ *
62
50
  * @param length - Token length in bytes (default: 32)
63
51
  * @returns Hex-encoded random string
64
52
  */
@@ -1,49 +1,36 @@
1
1
  /**
2
- * Password hashing and verification using Node.js crypto scrypt.
2
+ * Password hashing and verification, delegated to `@zudojs/crypto`.
3
3
  *
4
4
  * @module authPassword/authPassword
5
5
  *
6
- * Uses scrypt with salt for secure password hashing.
7
- * Compatible with Node.js ≥ 24 (no external dependencies).
6
+ * New hashes are `@zudojs/crypto` scrypt hashes
7
+ * (`v1$scrypt$N$r$p$<salt>.<hash>`, Base64URL). Hashes written by earlier
8
+ * versions of this package (`scrypt$…`) still verify through the legacy
9
+ * verifier, and `needsRehash()` reports them so they upgrade on next login.
8
10
  */
9
- import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
11
+ import { randomBytes } from "node:crypto";
12
+ import { hashPassword as cryptoHashPassword, verifyPassword as cryptoVerifyPassword, } from "@zudojs/crypto";
10
13
  import { ErrorCode } from "@zudojs/errors";
11
14
  import { AuthError } from "../authErrors/authError.base.js";
12
- /** Default salt length in bytes. */
13
- const SALT_LENGTH = 32;
14
- /** Accepted range for a caller-supplied salt length, in bytes. */
15
- export const MIN_SALT_LENGTH = 16;
16
- export const MAX_SALT_LENGTH = 64;
15
+ import { KEY_LENGTH, MAX_PASSWORD_BYTES, MAX_SALT_LENGTH, MIN_SALT_LENGTH, SALT_LENGTH, SCRYPT_N, SCRYPT_P, SCRYPT_R, } from "./authPassword.policy.js";
16
+ import { isLegacyPasswordHash, verifyLegacyPassword, } from "./authPassword.legacy.js";
17
17
  /**
18
- * Maximum accepted password length in bytes.
18
+ * Hash a plain-text password with `@zudojs/crypto` (scrypt N=2^14, r=8,
19
+ * p=5, 64-byte key).
19
20
  *
20
- * scrypt's cost is set by N/r, not by the input length, so a long password
21
- * is not a work-factor amplifier — but it is still an unbounded allocation
22
- * driven by an unauthenticated request body. 1024 bytes is far past any
23
- * real passphrase.
24
- */
25
- export const MAX_PASSWORD_BYTES = 1024;
26
- /** Default key length for scrypt. */
27
- const KEY_LENGTH = 64;
28
- /** Scrypt parameters (N, r, p). */
29
- const SCRYPT_N = 16384;
30
- const SCRYPT_R = 8;
31
- const SCRYPT_P = 1;
32
- /**
33
- * Hash a plain-text password.
34
- *
35
- * @param password - Plain-text password. Must be at most
21
+ * @param password - Plain-text password. Must be non-empty and at most
36
22
  * {@link MAX_PASSWORD_BYTES} bytes of UTF-8.
37
23
  * @param saltLength - Salt length in bytes (default: 32). Must be an integer
38
24
  * between {@link MIN_SALT_LENGTH} and {@link MAX_SALT_LENGTH}; `0` would
39
25
  * otherwise silently produce unsalted, rainbow-table-able hashes.
40
- * @returns Hashed password string in format "scrypt$N$r$p$salt$hash"
26
+ * @returns Hashed password string in the `@zudojs/crypto` format
27
+ * `v1$scrypt$N$r$p$<salt>.<hash>`
41
28
  * @throws {AuthError} with `ErrorCode.INVALID_INPUT` when the password is
42
- * not a string, is too long, or the salt length is out of range.
29
+ * not a non-empty string, is too long, or the salt length is out of range.
43
30
  */
44
31
  export async function hashPassword(password, saltLength = SALT_LENGTH) {
45
- if (typeof password !== "string") {
46
- throw new AuthError("Password must be a string.", {
32
+ if (typeof password !== "string" || password.length === 0) {
33
+ throw new AuthError("Password must be a non-empty string.", {
47
34
  code: ErrorCode.INVALID_INPUT,
48
35
  statusCode: 400,
49
36
  });
@@ -56,19 +43,21 @@ export async function hashPassword(password, saltLength = SALT_LENGTH) {
56
43
  saltLength > MAX_SALT_LENGTH) {
57
44
  throw new AuthError(`saltLength must be an integer between ${MIN_SALT_LENGTH} and ${MAX_SALT_LENGTH} bytes.`, { code: ErrorCode.INVALID_INPUT, statusCode: 400 });
58
45
  }
59
- const salt = randomBytes(saltLength).toString("hex");
60
- const derivedKey = await deriveKey(password, salt, {
61
- N: SCRYPT_N,
62
- r: SCRYPT_R,
63
- p: SCRYPT_P,
46
+ const result = await cryptoHashPassword(password, {
47
+ saltBytes: saltLength,
48
+ keyBytes: KEY_LENGTH,
49
+ cost: SCRYPT_N,
50
+ blockSize: SCRYPT_R,
51
+ parallelization: SCRYPT_P,
64
52
  });
65
- return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt}$${derivedKey}`;
53
+ return result.encoded;
66
54
  }
67
55
  /**
68
56
  * Verify a plain-text password against a hash.
69
57
  *
70
- * Accepts the current "scrypt$N$r$p$salt$hash" format as well as the
71
- * legacy "scrypt<salt>$<hash>" format produced by versions ≤ 0.1.1.
58
+ * `v1$…` hashes are verified by `@zudojs/crypto`. Hashes from earlier
59
+ * versions of this package ("scrypt$N$r$p$salt$hash" and the ≤ 0.1.1
60
+ * "scrypt<salt>$<hash>") go through the legacy verifier.
72
61
  *
73
62
  * Never throws: any input this function cannot make sense of — a
74
63
  * non-string, an over-length password (see {@link MAX_PASSWORD_BYTES}), an
@@ -87,44 +76,17 @@ export async function verifyPassword(password, hashedPassword) {
87
76
  if (Buffer.byteLength(password, "utf-8") > MAX_PASSWORD_BYTES) {
88
77
  return false;
89
78
  }
90
- const parsed = parseHash(hashedPassword);
91
- if (!parsed)
92
- return false;
93
- let derivedKey;
94
- try {
95
- derivedKey = await deriveKey(password, parsed.salt, parsed.params);
96
- }
97
- catch {
98
- // scrypt rejects params it cannot satisfy (e.g. over maxmem)
99
- return false;
100
- }
101
- const storedBuffer = Buffer.from(parsed.hash, "hex");
102
- const derivedBuffer = Buffer.from(derivedKey, "hex");
103
- if (storedBuffer.length !== derivedBuffer.length) {
104
- return false;
79
+ if (isLegacyPasswordHash(hashedPassword)) {
80
+ return verifyLegacyPassword(password, hashedPassword);
105
81
  }
106
- return timingSafeEqual(storedBuffer, derivedBuffer);
107
- }
108
- /**
109
- * Check if a password hash needs rehashing (legacy format, changed
110
- * scrypt parameters, or changed salt length).
111
- *
112
- * @param hashedPassword - The stored hash
113
- * @returns Whether the hash should be regenerated
114
- */
115
- export function needsRehash(hashedPassword) {
116
- const parsed = parseHash(hashedPassword);
117
- if (!parsed || parsed.legacy)
118
- return true;
119
- const saltBytes = parsed.salt.length / 2;
120
- return (saltBytes !== SALT_LENGTH ||
121
- parsed.params.N !== SCRYPT_N ||
122
- parsed.params.r !== SCRYPT_R ||
123
- parsed.params.p !== SCRYPT_P);
82
+ return cryptoVerifyPassword(password, hashedPassword);
124
83
  }
125
84
  /**
126
85
  * Generate a random token string (for password reset, etc.).
127
86
  *
87
+ * Synchronous by contract, so it draws from `node:crypto` directly: every
88
+ * `@zudojs/crypto` random helper is asynchronous.
89
+ *
128
90
  * @param length - Token length in bytes (default: 32)
129
91
  * @returns Hex-encoded random string
130
92
  */
@@ -134,52 +96,4 @@ export function generateRandomToken(length = 32) {
134
96
  }
135
97
  return randomBytes(length).toString("hex");
136
98
  }
137
- function parseHash(hashedPassword) {
138
- const parts = hashedPassword.split("$");
139
- if (parts.length === 6 && parts[0] === "scrypt") {
140
- const N = Number(parts[1]);
141
- const r = Number(parts[2]);
142
- const p = Number(parts[3]);
143
- // Bounds also cap the memory/CPU a corrupted hash string can request.
144
- if (!Number.isInteger(N) ||
145
- !Number.isInteger(r) ||
146
- !Number.isInteger(p) ||
147
- N < 2 ||
148
- (N & (N - 1)) !== 0 ||
149
- N > 1 << 20 ||
150
- r < 1 ||
151
- r > 64 ||
152
- p < 1 ||
153
- p > 16) {
154
- return null;
155
- }
156
- return {
157
- params: { N, r, p },
158
- salt: parts[4],
159
- hash: parts[5],
160
- legacy: false,
161
- };
162
- }
163
- // Legacy format from ≤ 0.1.1: "scrypt<salt>$<hash>" (no separator
164
- // between the prefix and the salt, params not stored).
165
- if (parts.length === 2 && parts[0].startsWith("scrypt")) {
166
- return {
167
- params: { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P },
168
- salt: parts[0].slice(6),
169
- hash: parts[1],
170
- legacy: true,
171
- };
172
- }
173
- return null;
174
- }
175
- function deriveKey(password, salt, params) {
176
- return new Promise((resolve, reject) => {
177
- scrypt(password, salt, KEY_LENGTH, { N: params.N, r: params.r, p: params.p }, (err, derivedKey) => {
178
- if (err)
179
- reject(err);
180
- else
181
- resolve(derivedKey.toString("hex"));
182
- });
183
- });
184
- }
185
99
  //# sourceMappingURL=authPassword.core.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Verifier for password hashes written before auth delegated to
3
+ * `@zudojs/crypto`: `scrypt$N$r$p$<hexSalt>$<hexHash>` and the param-less
4
+ * `scrypt<hexSalt>$<hexHash>` format from versions ≤ 0.1.1.
5
+ *
6
+ * Nothing new is ever written in these formats. They are kept so stored
7
+ * hashes still verify, and `needsRehash()` reports every one of them.
8
+ *
9
+ * @module authPassword/authPassword.legacy
10
+ */
11
+ /**
12
+ * Returns whether a stored hash uses one of the legacy auth formats (the
13
+ * string starts with `scrypt`). Crypto-format hashes start with `v1$`.
14
+ */
15
+ export declare function isLegacyPasswordHash(hashedPassword: string): boolean;
16
+ /**
17
+ * Verifies a password against a legacy hash. Returns `false` for an
18
+ * unparseable hash or parameters scrypt cannot satisfy; never throws.
19
+ */
20
+ export declare function verifyLegacyPassword(password: string, hashedPassword: string): Promise<boolean>;
21
+ //# sourceMappingURL=authPassword.legacy.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Verifier for password hashes written before auth delegated to
3
+ * `@zudojs/crypto`: `scrypt$N$r$p$<hexSalt>$<hexHash>` and the param-less
4
+ * `scrypt<hexSalt>$<hexHash>` format from versions ≤ 0.1.1.
5
+ *
6
+ * Nothing new is ever written in these formats. They are kept so stored
7
+ * hashes still verify, and `needsRehash()` reports every one of them.
8
+ *
9
+ * @module authPassword/authPassword.legacy
10
+ */
11
+ import { scrypt } from "node:crypto";
12
+ import { timingSafeEqual } from "@zudojs/crypto";
13
+ /** Derived-key length used by every legacy format. */
14
+ const LEGACY_KEY_LENGTH = 64;
15
+ /** The fixed parameters of the param-less legacy format (≤ 0.1.1). */
16
+ const PARAMLESS_PARAMS = Object.freeze({
17
+ N: 16384,
18
+ r: 8,
19
+ p: 1,
20
+ });
21
+ /**
22
+ * Returns whether a stored hash uses one of the legacy auth formats (the
23
+ * string starts with `scrypt`). Crypto-format hashes start with `v1$`.
24
+ */
25
+ export function isLegacyPasswordHash(hashedPassword) {
26
+ return hashedPassword.startsWith("scrypt");
27
+ }
28
+ /**
29
+ * Verifies a password against a legacy hash. Returns `false` for an
30
+ * unparseable hash or parameters scrypt cannot satisfy; never throws.
31
+ */
32
+ export async function verifyLegacyPassword(password, hashedPassword) {
33
+ const parsed = parseLegacyHash(hashedPassword);
34
+ if (!parsed)
35
+ return false;
36
+ let derived;
37
+ try {
38
+ derived = await deriveLegacyKey(password, parsed.salt, parsed.params);
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ return timingSafeEqual(Buffer.from(parsed.hash, "hex"), derived);
44
+ }
45
+ function parseLegacyHash(hashedPassword) {
46
+ const parts = hashedPassword.split("$");
47
+ if (parts.length === 6 && parts[0] === "scrypt") {
48
+ const N = Number(parts[1]);
49
+ const r = Number(parts[2]);
50
+ const p = Number(parts[3]);
51
+ // Bounds also cap the memory/CPU a corrupted hash string can request.
52
+ if (!Number.isInteger(N) ||
53
+ !Number.isInteger(r) ||
54
+ !Number.isInteger(p) ||
55
+ N < 2 ||
56
+ (N & (N - 1)) !== 0 ||
57
+ N > 1 << 20 ||
58
+ r < 1 ||
59
+ r > 64 ||
60
+ p < 1 ||
61
+ p > 16) {
62
+ return null;
63
+ }
64
+ return { params: { N, r, p }, salt: parts[4], hash: parts[5] };
65
+ }
66
+ if (parts.length === 2 && parts[0].startsWith("scrypt")) {
67
+ return {
68
+ params: PARAMLESS_PARAMS,
69
+ salt: parts[0].slice(6),
70
+ hash: parts[1],
71
+ };
72
+ }
73
+ return null;
74
+ }
75
+ function deriveLegacyKey(password, salt, params) {
76
+ return new Promise((resolve, reject) => {
77
+ scrypt(password, salt, LEGACY_KEY_LENGTH, { N: params.N, r: params.r, p: params.p }, (err, derivedKey) => {
78
+ if (err)
79
+ reject(err);
80
+ else
81
+ resolve(derivedKey);
82
+ });
83
+ });
84
+ }
85
+ //# sourceMappingURL=authPassword.legacy.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Hashing policy for new auth password hashes. `needsRehash()` flags any
3
+ * stored hash that does not match it.
4
+ *
5
+ * @module authPassword/authPassword.policy
6
+ */
7
+ /** Default salt length in bytes. */
8
+ export declare const SALT_LENGTH = 32;
9
+ /** Accepted range for a caller-supplied salt length, in bytes. */
10
+ export declare const MIN_SALT_LENGTH = 16;
11
+ export declare const MAX_SALT_LENGTH = 64;
12
+ /**
13
+ * Maximum accepted password length in bytes.
14
+ *
15
+ * scrypt's cost is set by N/r, not by the input length, so a long password
16
+ * is not a work-factor amplifier — but it is still an unbounded allocation
17
+ * driven by an unauthenticated request body. 1024 bytes is far past any
18
+ * real passphrase.
19
+ */
20
+ export declare const MAX_PASSWORD_BYTES = 1024;
21
+ /** Derived key length in bytes. */
22
+ export declare const KEY_LENGTH = 64;
23
+ /**
24
+ * Scrypt parameters (N, r, p) for new hashes: the OWASP Password Storage
25
+ * Cheat Sheet's N=2^14, r=8, p=5 row.
26
+ */
27
+ export declare const SCRYPT_N = 16384;
28
+ export declare const SCRYPT_R = 8;
29
+ export declare const SCRYPT_P = 5;
30
+ //# sourceMappingURL=authPassword.policy.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Hashing policy for new auth password hashes. `needsRehash()` flags any
3
+ * stored hash that does not match it.
4
+ *
5
+ * @module authPassword/authPassword.policy
6
+ */
7
+ /** Default salt length in bytes. */
8
+ export const SALT_LENGTH = 32;
9
+ /** Accepted range for a caller-supplied salt length, in bytes. */
10
+ export const MIN_SALT_LENGTH = 16;
11
+ export const MAX_SALT_LENGTH = 64;
12
+ /**
13
+ * Maximum accepted password length in bytes.
14
+ *
15
+ * scrypt's cost is set by N/r, not by the input length, so a long password
16
+ * is not a work-factor amplifier — but it is still an unbounded allocation
17
+ * driven by an unauthenticated request body. 1024 bytes is far past any
18
+ * real passphrase.
19
+ */
20
+ export const MAX_PASSWORD_BYTES = 1024;
21
+ /** Derived key length in bytes. */
22
+ export const KEY_LENGTH = 64;
23
+ /**
24
+ * Scrypt parameters (N, r, p) for new hashes: the OWASP Password Storage
25
+ * Cheat Sheet's N=2^14, r=8, p=5 row.
26
+ */
27
+ export const SCRYPT_N = 16384;
28
+ export const SCRYPT_R = 8;
29
+ export const SCRYPT_P = 5;
30
+ //# sourceMappingURL=authPassword.policy.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Rehash detection for stored password hashes.
3
+ *
4
+ * @module authPassword/authPassword.rehash
5
+ */
6
+ /**
7
+ * Check if a password hash needs rehashing: every hash that is not a
8
+ * `@zudojs/crypto` scrypt hash with the current parameters (N, r, p, salt
9
+ * and key length). All legacy `scrypt$…` hashes return `true`.
10
+ *
11
+ * @param hashedPassword - The stored hash
12
+ * @returns Whether the hash should be regenerated
13
+ */
14
+ export declare function needsRehash(hashedPassword: string): boolean;
15
+ //# sourceMappingURL=authPassword.rehash.d.ts.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Rehash detection for stored password hashes.
3
+ *
4
+ * @module authPassword/authPassword.rehash
5
+ */
6
+ import { CryptoAlgorithm, decodePasswordHash } from "@zudojs/crypto";
7
+ import { KEY_LENGTH, SALT_LENGTH, SCRYPT_N, SCRYPT_P, SCRYPT_R, } from "./authPassword.policy.js";
8
+ /**
9
+ * Check if a password hash needs rehashing: every hash that is not a
10
+ * `@zudojs/crypto` scrypt hash with the current parameters (N, r, p, salt
11
+ * and key length). All legacy `scrypt$…` hashes return `true`.
12
+ *
13
+ * @param hashedPassword - The stored hash
14
+ * @returns Whether the hash should be regenerated
15
+ */
16
+ export function needsRehash(hashedPassword) {
17
+ if (typeof hashedPassword !== "string")
18
+ return true;
19
+ try {
20
+ const decoded = decodePasswordHash(hashedPassword);
21
+ if (decoded.algorithm !== CryptoAlgorithm.SCRYPT)
22
+ return true;
23
+ return (decoded.salt.byteLength !== SALT_LENGTH ||
24
+ decoded.hash.byteLength !== KEY_LENGTH ||
25
+ decoded.cost !== SCRYPT_N ||
26
+ decoded.blockSize !== SCRYPT_R ||
27
+ decoded.parallelization !== SCRYPT_P);
28
+ }
29
+ catch {
30
+ return true;
31
+ }
32
+ }
33
+ //# sourceMappingURL=authPassword.rehash.js.map