@crombolo/auth 0.11.0 → 0.13.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/auth",
3
- "version": "0.11.0",
3
+ "version": "0.13.1",
4
4
  "description": "OAuth2 authorization server, password hashing, cookies and member tickets for crombolo.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.11.0",
32
- "@crombolo/data": "0.11.0"
31
+ "@crombolo/core": "0.13.1",
32
+ "@crombolo/data": "0.13.1"
33
33
  }
34
34
  }
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@ export * from './pkce.ts'
6
6
  export * from './routes.ts'
7
7
  export * from './service.ts'
8
8
  export * from './store.ts'
9
+ export * from './umbraco-passwords.ts'
package/src/password.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Password hashing.
3
- *
4
- * Umbraco stores ASP.NET Core Identity v3 hashes (PBKDF2-HMAC-SHA512). We are not
5
- * reading existing Umbraco databases, so we use argon2id instead, and keep the
6
- * `password_config` marker column so the algorithm is recorded per row and can be
7
- * rotated later exactly as Umbraco rotates its legacy formats.
2
+ * Password hashing: argon2id, with the algorithm recorded per row in
3
+ * `password_config`. A hash imported from Umbraco (story 071) is verified in
4
+ * its own format (`umbraco-passwords.ts`) and replaced with argon2id on the
5
+ * first successful sign-in, as Umbraco rotates its legacy formats.
8
6
  */
7
+ import { verifyUmbracoPassword } from './umbraco-passwords.ts'
8
+
9
9
  export const PASSWORD_ALGORITHM = 'ARGON2ID'
10
10
 
11
11
  export interface PasswordConfig {
@@ -19,12 +19,23 @@ export async function hashPassword(password: string): Promise<string> {
19
19
  return Bun.password.hash(password, { algorithm: 'argon2id' })
20
20
  }
21
21
 
22
+ /** Whether a stored hash is crombolo's own; anything else is replaced on a successful sign-in. Pure. */
23
+ export const isNativeHash = (hash: string | null | undefined): boolean =>
24
+ typeof hash === 'string' && hash.startsWith('$argon2')
25
+
22
26
  /**
23
27
  * Verifies a password. Returns false rather than throwing for a malformed or
24
- * absent hash, so a user row with no password simply cannot sign in.
28
+ * absent hash, so a user row with no password simply cannot sign in. A hash
29
+ * that is not argon2id is tried as Umbraco's, by its `passwordConfig`.
25
30
  */
26
- export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
31
+ export async function verifyPassword(
32
+ password: string,
33
+ hash: string | null,
34
+ imported: { passwordConfig?: string | null; forMember?: boolean } = {},
35
+ ): Promise<boolean> {
27
36
  if (!hash) return false
37
+ if (!isNativeHash(hash))
38
+ return verifyUmbracoPassword(password, hash, imported.passwordConfig, imported.forMember)
28
39
  try {
29
40
  return await Bun.password.verify(password, hash)
30
41
  } catch {
package/src/service.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * actually sends. See docs/backoffice-hosting.md for the wire contract.
4
4
  */
5
5
  import { type ExtensionSectionAlias, type SectionAlias, toSectionAliases } from '@crombolo/core'
6
- import { hashPassword, passwordConfigJson, verifyPassword } from './password.ts'
6
+ import { hashPassword, isNativeHash, passwordConfigJson, verifyPassword } from './password.ts'
7
7
  import { type CodeChallengeMethod, generateToken, verifyChallenge } from './pkce.ts'
8
8
  import type { AuthStore, UserRow } from './store.ts'
9
9
 
@@ -131,7 +131,11 @@ export class AuthService {
131
131
  await verifyPassword(password, await DUMMY_HASH_PROMISE)
132
132
  return { status: 'notAllowed', reason: 'lockedOut', user } as const
133
133
  }
134
- if (!(await verifyPassword(password, user.passwordHash))) {
134
+ if (
135
+ !(await verifyPassword(password, user.passwordHash, {
136
+ passwordConfig: user.passwordConfig,
137
+ }))
138
+ ) {
135
139
  const attempts = await this.#store.recordLoginFailure(user.id)
136
140
  const lockedOut =
137
141
  this.#settings.maxFailedLoginAttempts > 0 &&
@@ -143,6 +147,13 @@ export class AuthService {
143
147
  // attacker can induce, so answering it only to whoever knows the password
144
148
  // discloses strictly less than checking it up front.
145
149
  if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved', user } as const
150
+ // An imported Umbraco hash is replaced now the password is known to match it.
151
+ if (!isNativeHash(user.passwordHash))
152
+ await this.#store.rehashPassword(
153
+ user.id,
154
+ await hashPassword(password),
155
+ passwordConfigJson(),
156
+ )
146
157
 
147
158
  const sessionId = crypto.randomUUID()
148
159
  await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
package/src/store.ts CHANGED
@@ -14,6 +14,8 @@ export interface UserRow {
14
14
  login: string
15
15
  email: string
16
16
  passwordHash: string | null
17
+ /** The algorithm marker beside the hash; an imported one names Umbraco's. */
18
+ passwordConfig: string | null
17
19
  language: string | null
18
20
  avatar: string | null
19
21
  isLockedOut: boolean
@@ -71,6 +73,7 @@ function mapUser(row: Record<string, unknown>): UserRow {
71
73
  login: String(row.login),
72
74
  email: String(row.email),
73
75
  passwordHash: (row.password_hash as string | null) ?? null,
76
+ passwordConfig: (row.password_config as string | null) ?? null,
74
77
  language: (row.language as string | null) ?? null,
75
78
  avatar: (row.avatar as string | null) ?? null,
76
79
  isLockedOut: fromDbBool(row.is_locked_out),
@@ -81,7 +84,7 @@ function mapUser(row: Record<string, unknown>): UserRow {
81
84
  }
82
85
 
83
86
  const USER_COLUMNS =
84
- 'id, key, user_name, login, email, password_hash, language, avatar, is_locked_out, is_approved, failed_login_attempts, kind'
87
+ 'id, key, user_name, login, email, password_hash, password_config, language, avatar, is_locked_out, is_approved, failed_login_attempts, kind'
85
88
 
86
89
  export class AuthStore {
87
90
  #db: Db
@@ -154,6 +157,21 @@ export class AuthStore {
154
157
  * Replaces a user's password. The caller supplies an already-hashed value, so
155
158
  * this module never needs to know which algorithm is in use.
156
159
  */
160
+ /**
161
+ * Replaces an imported hash with argon2id after a successful sign-in. The
162
+ * password is the same, so the security stamp and change date stand.
163
+ */
164
+ async rehashPassword(
165
+ userId: number,
166
+ passwordHash: string,
167
+ passwordConfig: string,
168
+ ): Promise<void> {
169
+ await this.#db.exec(
170
+ 'UPDATE user_account SET password_hash = ?, password_config = ? WHERE id = ?',
171
+ [passwordHash, passwordConfig, userId],
172
+ )
173
+ }
174
+
157
175
  async setPassword(userId: number, passwordHash: string, passwordConfig: string): Promise<void> {
158
176
  const now = DbDate.toDb(new Date())
159
177
  await this.#db.exec(
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Verifying the password hashes an Umbraco site stores, so an imported user or
3
+ * member signs in with the password they had; a success is then re-hashed to
4
+ * argon2id. In the order Umbraco's `UmbracoPasswordHasher` and
5
+ * `MemberPasswordHasher` try them:
6
+ *
7
+ * - ASP.NET Core Identity v3 (`0x01`, PBKDF2 with the PRF and iterations it
8
+ * records) and v2 (`0x00`, PBKDF2-HMAC-SHA1, 1,000 iterations);
9
+ * - Umbraco 4's HMAC-SHA1 keyed with the password itself;
10
+ * - Umbraco 6–8's HMAC-SHA256 (or plain SHA1) over a base64 salt that prefixes
11
+ * the hash, named by `passwordConfig` — or assumed for a member with none,
12
+ * as a site upgraded from 8 has.
13
+ *
14
+ * Every comparison is constant-time. Pure.
15
+ */
16
+ import { createHash, createHmac, pbkdf2Sync, timingSafeEqual } from 'node:crypto'
17
+
18
+ export const UMBRACO_PASSWORD_ALGORITHMS = [
19
+ 'PBKDF2.ASPNETCORE.V3',
20
+ 'PBKDF2.ASPNETCORE.V2',
21
+ 'HMACSHA256',
22
+ 'HMACSHA1',
23
+ ] as const
24
+
25
+ const same = (a: Buffer, b: Buffer) => a.length === b.length && timingSafeEqual(a, b)
26
+
27
+ const utf16 = (value: string) => Buffer.from(value, 'utf16le')
28
+
29
+ function base64(value: string): Buffer | undefined {
30
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) return undefined
31
+ return Buffer.from(value, 'base64')
32
+ }
33
+
34
+ const PRFS = ['sha1', 'sha256', 'sha512'] as const
35
+
36
+ /** ASP.NET Core Identity v3 or v2, by its first byte; the password as UTF-8. */
37
+ export function verifyAspNetIdentity(password: string, hash: string): boolean {
38
+ const bytes = base64(hash)
39
+ if (!bytes || bytes.length === 0) return false
40
+ if (bytes[0] === 0x00) {
41
+ if (bytes.length !== 49) return false
42
+ const salt = bytes.subarray(1, 17)
43
+ const expected = bytes.subarray(17)
44
+ return same(pbkdf2Sync(password, salt, 1000, 32, 'sha1'), expected)
45
+ }
46
+ if (bytes[0] === 0x01) {
47
+ if (bytes.length < 13) return false
48
+ const prf = PRFS[bytes.readUInt32BE(1)]
49
+ const iterations = bytes.readUInt32BE(5)
50
+ const saltLength = bytes.readUInt32BE(9)
51
+ if (!prf || iterations < 1 || saltLength < 16 || bytes.length < 13 + saltLength + 16)
52
+ return false
53
+ const salt = bytes.subarray(13, 13 + saltLength)
54
+ const expected = bytes.subarray(13 + saltLength)
55
+ return same(pbkdf2Sync(password, salt, iterations, expected.length, prf), expected)
56
+ }
57
+ return false
58
+ }
59
+
60
+ /** Umbraco 4's "legacy encoding": HMAC-SHA1 whose key is the password, over the password. */
61
+ export function verifyUmbraco4(password: string, hash: string): boolean {
62
+ const expected = base64(hash)
63
+ if (!expected) return false
64
+ return same(createHmac('sha1', utf16(password)).update(utf16(password)).digest(), expected)
65
+ }
66
+
67
+ /**
68
+ * Umbraco 6–8: a 24-character base64 salt, then the base64 hash of the
69
+ * password (UTF-16) under HMAC-SHA256 keyed with the salt repeated to 64
70
+ * bytes — .NET's key length for it — or SHA1 over salt and password.
71
+ */
72
+ export function verifyUmbraco8(password: string, hash: string, algorithm = 'HMACSHA256'): boolean {
73
+ if (hash.length <= 24) return false
74
+ const salt = base64(hash.slice(0, 24))
75
+ const expected = base64(hash.slice(24))
76
+ if (!salt || !expected || salt.length === 0) return false
77
+ const name = algorithm.toUpperCase()
78
+ if (name === 'HMACSHA256') {
79
+ const key = Buffer.alloc(64)
80
+ for (let at = 0; at < key.length; at += salt.length)
81
+ salt.copy(key, at, 0, Math.min(salt.length, key.length - at))
82
+ return same(createHmac('sha256', key).update(utf16(password)).digest(), expected)
83
+ }
84
+ if (name === 'SHA1')
85
+ return same(
86
+ createHash('sha1')
87
+ .update(Buffer.concat([salt, utf16(password)]))
88
+ .digest(),
89
+ expected,
90
+ )
91
+ return false
92
+ }
93
+
94
+ /** The `hashAlgorithm` an Umbraco `passwordConfig` names, when it names one. Pure. */
95
+ export function umbracoAlgorithmOf(passwordConfig: string | null | undefined): string | undefined {
96
+ if (!passwordConfig) return undefined
97
+ try {
98
+ const parsed = JSON.parse(passwordConfig) as { hashAlgorithm?: unknown }
99
+ return typeof parsed.hashAlgorithm === 'string' ? parsed.hashAlgorithm : undefined
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Whether a password matches a hash an Umbraco site stored. `forMember`: a
107
+ * member with no `passwordConfig` is assumed to be on Umbraco 8's hashing,
108
+ * which is how Umbraco treats one upgraded from 8.
109
+ */
110
+ export function verifyUmbracoPassword(
111
+ password: string,
112
+ hash: string | null | undefined,
113
+ passwordConfig: string | null | undefined,
114
+ forMember = false,
115
+ ): boolean {
116
+ if (!hash || !password) return false
117
+ if (verifyAspNetIdentity(password, hash)) return true
118
+ if (verifyUmbraco4(password, hash)) return true
119
+ const algorithm = umbracoAlgorithmOf(passwordConfig) ?? (forMember ? 'HMACSHA256' : undefined)
120
+ if (!algorithm) return false
121
+ return verifyUmbraco8(password, hash, algorithm)
122
+ }
123
+
124
+ /** Whether a `passwordConfig` is one of Umbraco's, rather than crombolo's argon2id. Pure. */
125
+ export const isUmbracoPasswordConfig = (passwordConfig: string | null | undefined): boolean => {
126
+ const algorithm = umbracoAlgorithmOf(passwordConfig)
127
+ return algorithm !== undefined && algorithm.toUpperCase() !== 'ARGON2ID'
128
+ }