@meith/accounts 0.16.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.
@@ -0,0 +1,134 @@
1
+ import { ForbiddenError } from '@meith/core'
2
+
3
+ import { generateToken, hashToken } from './crypto/tokens'
4
+ import type { AccountRecord, AccountStore, Clock } from './ports'
5
+ import type { RequestContext } from './service'
6
+
7
+ export interface SessionServiceDeps {
8
+ readonly store: AccountStore
9
+ readonly rememberDays: number
10
+ readonly sessionLifetimeDays: number
11
+ readonly assertSignInAllowed?: (account: AccountRecord) => Promise<void>
12
+ readonly clock?: Clock
13
+ }
14
+
15
+ export interface RememberedLogin {
16
+ readonly userId: number
17
+ readonly sessionToken: string
18
+ readonly sessionExpiresAt: Date
19
+ readonly rememberToken: string
20
+ readonly rememberExpiresAt: Date
21
+ }
22
+
23
+ export type ResumeOutcome =
24
+ | { readonly status: 'ok'; readonly login: RememberedLogin }
25
+ | { readonly status: 'reuse'; readonly userId: number }
26
+ | { readonly status: 'invalid' }
27
+
28
+ const DAY_MS = 86_400_000
29
+
30
+ export class SessionService {
31
+ private readonly store: AccountStore
32
+ private readonly rememberDays: number
33
+ private readonly sessionLifetimeDays: number
34
+ private readonly assertSignInAllowed: (account: AccountRecord) => Promise<void>
35
+ private readonly now: Clock
36
+
37
+ constructor(deps: SessionServiceDeps) {
38
+ this.store = deps.store
39
+ this.rememberDays = deps.rememberDays
40
+ this.sessionLifetimeDays = deps.sessionLifetimeDays
41
+ this.assertSignInAllowed = deps.assertSignInAllowed ?? (async () => undefined)
42
+ this.now = deps.clock ?? (() => new Date())
43
+ }
44
+
45
+ async start(
46
+ userId: number,
47
+ context: RequestContext = {},
48
+ ): Promise<{ token: string; expiresAt: Date }> {
49
+ return this.mintSession(userId, this.now(), context)
50
+ }
51
+
52
+ async startRemembered(userId: number, context: RequestContext = {}): Promise<RememberedLogin> {
53
+ const at = this.now()
54
+ const familyId = generateToken()
55
+ const rememberToken = generateToken()
56
+ const rememberExpiresAt = new Date(at.getTime() + this.rememberDays * DAY_MS)
57
+ await this.store.remember.issue({
58
+ tokenHash: await hashToken(rememberToken),
59
+ familyId,
60
+ userId,
61
+ expiresAt: rememberExpiresAt,
62
+ })
63
+ const session = await this.mintSession(userId, at, context)
64
+ return {
65
+ userId,
66
+ sessionToken: session.token,
67
+ sessionExpiresAt: session.expiresAt,
68
+ rememberToken,
69
+ rememberExpiresAt,
70
+ }
71
+ }
72
+
73
+ async resume(rememberToken: string, context: RequestContext = {}): Promise<ResumeOutcome> {
74
+ const at = this.now()
75
+ const presentedHash = await hashToken(rememberToken)
76
+ const held = await this.store.remember.findByTokenHash(presentedHash)
77
+ if (held === null) return { status: 'invalid' }
78
+
79
+ const account = await this.store.accounts.findById(held.userId)
80
+ if (account === null) return { status: 'invalid' }
81
+
82
+ try {
83
+ await this.assertSignInAllowed(account)
84
+ } catch (error) {
85
+ if (error instanceof ForbiddenError) return { status: 'invalid' }
86
+ throw error
87
+ }
88
+
89
+ const nextToken = generateToken()
90
+ const rotation = await this.store.remember.rotate({
91
+ presentedHash,
92
+ nextHash: await hashToken(nextToken),
93
+ now: at,
94
+ nextExpiresAt: new Date(at.getTime() + this.rememberDays * DAY_MS),
95
+ })
96
+
97
+ if (rotation.status === 'invalid') return { status: 'invalid' }
98
+
99
+ if (rotation.status === 'reuse') {
100
+ await this.store.remember.revokeAllForUser(rotation.userId, 'token_reuse', at)
101
+ await this.store.sessions.revokeAllForUser(rotation.userId)
102
+ return { status: 'reuse', userId: rotation.userId }
103
+ }
104
+
105
+ const session = await this.mintSession(rotation.userId, at, context)
106
+ return {
107
+ status: 'ok',
108
+ login: {
109
+ userId: rotation.userId,
110
+ sessionToken: session.token,
111
+ sessionExpiresAt: session.expiresAt,
112
+ rememberToken: nextToken,
113
+ rememberExpiresAt: new Date(at.getTime() + this.rememberDays * DAY_MS),
114
+ },
115
+ }
116
+ }
117
+
118
+ private async mintSession(
119
+ userId: number,
120
+ at: Date,
121
+ context: RequestContext = {},
122
+ ): Promise<{ token: string; expiresAt: Date }> {
123
+ const token = generateToken()
124
+ const expiresAt = new Date(at.getTime() + this.sessionLifetimeDays * DAY_MS)
125
+ await this.store.sessions.create({
126
+ tokenHash: await hashToken(token),
127
+ userId,
128
+ expiresAt,
129
+ ipPrefix: context.ipPrefix ?? null,
130
+ userAgent: context.userAgent ?? null,
131
+ })
132
+ return { token, expiresAt }
133
+ }
134
+ }
@@ -0,0 +1,8 @@
1
+ export async function rejectionMessage(p: Promise<unknown>): Promise<string> {
2
+ try {
3
+ await p
4
+ } catch (error) {
5
+ return error instanceof Error ? error.message : String(error)
6
+ }
7
+ throw new Error('expected the promise to reject, but it resolved')
8
+ }
@@ -0,0 +1,52 @@
1
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
2
+
3
+ export function encodeBase32(bytes: Uint8Array): string {
4
+ let out = ''
5
+ let buffer = 0
6
+ let bits = 0
7
+
8
+ for (const byte of bytes) {
9
+ buffer = (buffer << 8) | byte
10
+ bits += 8
11
+
12
+ while (bits >= 5) {
13
+ bits -= 5
14
+ out += ALPHABET[(buffer >> bits) & 0b11111]
15
+ }
16
+ }
17
+
18
+ if (bits > 0) out += ALPHABET[(buffer << (5 - bits)) & 0b11111]
19
+
20
+ return out
21
+ }
22
+
23
+ export function decodeBase32(value: string): Uint8Array {
24
+ const clean = value.replace(/[=\s-]/g, '').toUpperCase()
25
+ const bytes: number[] = []
26
+
27
+ let buffer = 0
28
+ let bits = 0
29
+
30
+ for (const character of clean) {
31
+ const digit = ALPHABET.indexOf(character)
32
+ if (digit < 0) throw new Error('not base32')
33
+
34
+ buffer = (buffer << 5) | digit
35
+ bits += 5
36
+
37
+ if (bits >= 8) {
38
+ bits -= 8
39
+ bytes.push((buffer >> bits) & 0xff)
40
+ }
41
+ }
42
+
43
+ return Uint8Array.from(bytes)
44
+ }
45
+
46
+ export function isBase32(value: string): boolean {
47
+ try {
48
+ return decodeBase32(value).length > 0
49
+ } catch {
50
+ return false
51
+ }
52
+ }
@@ -0,0 +1,83 @@
1
+ import { ConfigurationError } from '@meith/core'
2
+
3
+ import { decodeBase64Url, encodeBase64Url } from '../crypto/base64url'
4
+
5
+ const VERSION = 'v1'
6
+
7
+ const IV_BYTES = 12
8
+
9
+ const INFO = new TextEncoder().encode('meith/two-factor-secret')
10
+
11
+ /**
12
+ * The shared secret an authenticator app holds is a password equivalent: with
13
+ * it, anybody can mint that member's codes forever. It is sealed with a key
14
+ * derived from AUTH_SECRET so that a leaked backup, or a read of the table by
15
+ * anything that never had the environment, is not enough on its own.
16
+ */
17
+ export async function sealSecret(plaintext: string, passphrase: string): Promise<string> {
18
+ const key = await deriveKey(passphrase)
19
+ const iv = new Uint8Array(IV_BYTES)
20
+ crypto.getRandomValues(iv)
21
+
22
+ const sealed = new Uint8Array(
23
+ await crypto.subtle.encrypt(
24
+ { name: 'AES-GCM', iv: iv as unknown as BufferSource },
25
+ key,
26
+ new TextEncoder().encode(plaintext) as unknown as BufferSource,
27
+ ),
28
+ )
29
+
30
+ return `${VERSION}.${encodeBase64Url(iv)}.${encodeBase64Url(sealed)}`
31
+ }
32
+
33
+ export async function openSecret(sealed: string, passphrase: string): Promise<string | null> {
34
+ const parts = sealed.split('.')
35
+ if (parts.length !== 3 || parts[0] !== VERSION) return null
36
+
37
+ try {
38
+ const key = await deriveKey(passphrase)
39
+
40
+ const opened = await crypto.subtle.decrypt(
41
+ { name: 'AES-GCM', iv: decodeBase64Url(parts[1]!) as unknown as BufferSource },
42
+ key,
43
+ decodeBase64Url(parts[2]!) as unknown as BufferSource,
44
+ )
45
+
46
+ return new TextDecoder().decode(opened)
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ export function assertSealingKey(passphrase: string | undefined): string {
53
+ if (passphrase === undefined || passphrase.trim() === '') {
54
+ throw new ConfigurationError(
55
+ 'Two-factor authentication needs AUTH_SECRET set, because the secret an ' +
56
+ 'authenticator app holds is sealed with a key derived from it.',
57
+ )
58
+ }
59
+ return passphrase
60
+ }
61
+
62
+ async function deriveKey(passphrase: string): Promise<CryptoKey> {
63
+ const material = await crypto.subtle.importKey(
64
+ 'raw',
65
+ new TextEncoder().encode(passphrase) as unknown as BufferSource,
66
+ 'HKDF',
67
+ false,
68
+ ['deriveKey'],
69
+ )
70
+
71
+ return crypto.subtle.deriveKey(
72
+ {
73
+ name: 'HKDF',
74
+ hash: 'SHA-256',
75
+ salt: new Uint8Array() as unknown as BufferSource,
76
+ info: INFO as unknown as BufferSource,
77
+ },
78
+ material,
79
+ { name: 'AES-GCM', length: 256 },
80
+ false,
81
+ ['encrypt', 'decrypt'],
82
+ )
83
+ }
@@ -0,0 +1,252 @@
1
+ import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { hashToken } from '../crypto/tokens'
5
+ import type {
6
+ AccountRepository,
7
+ Clock,
8
+ RecoveryCodeRepository,
9
+ TwoFactorRepository,
10
+ } from '../ports'
11
+ import { encodeBase32 } from './base32'
12
+ import { openSecret, sealSecret } from './secret-box'
13
+ import { generateTotpSecret, matchTotp, otpauthUri } from './totp'
14
+
15
+ export const RECOVERY_CODE_COUNT = 10
16
+
17
+ export const RECOVERY_CODE_BYTES = 16
18
+
19
+ export const WRONG_CODE = 'That code is not right. Check your authenticator app and try again.'
20
+
21
+ export const REPLAYED_CODE =
22
+ 'That code has been used already. Wait for your authenticator app to show the next one.'
23
+
24
+ export interface TwoFactorDeps {
25
+ readonly accounts: AccountRepository
26
+ readonly twoFactor: TwoFactorRepository
27
+ readonly recoveryCodes: RecoveryCodeRepository
28
+ readonly sealingKey: string
29
+ readonly clock?: Clock
30
+ }
31
+
32
+ export interface Enrolment {
33
+ readonly secret: string
34
+ readonly uri: string
35
+ }
36
+
37
+ export interface TwoFactorState {
38
+ readonly enrolled: boolean
39
+ readonly pending: boolean
40
+ readonly recoveryCodesLeft: number
41
+ }
42
+
43
+ export type SecondFactorOutcome =
44
+ | { readonly status: 'ok'; readonly usedRecoveryCode: boolean }
45
+ | { readonly status: 'wrong' }
46
+ | { readonly status: 'replayed' }
47
+
48
+ export async function holdsSecondFactor(
49
+ twoFactor: TwoFactorRepository,
50
+ userId: number,
51
+ ): Promise<boolean> {
52
+ const record = await twoFactor.find(userId)
53
+ return record !== null && record.confirmedAt !== null
54
+ }
55
+
56
+ export async function clearSecondFactor(
57
+ store: {
58
+ readonly twoFactor: TwoFactorRepository
59
+ readonly recoveryCodes: RecoveryCodeRepository
60
+ },
61
+ userId: number,
62
+ ): Promise<boolean> {
63
+ const removed = await store.twoFactor.remove(userId)
64
+ await store.recoveryCodes.removeAll(userId)
65
+ return removed
66
+ }
67
+
68
+ export class TwoFactorService {
69
+ private readonly accounts: AccountRepository
70
+ private readonly twoFactor: TwoFactorRepository
71
+ private readonly recoveryCodes: RecoveryCodeRepository
72
+ private readonly sealingKey: string
73
+ private readonly now: Clock
74
+
75
+ constructor(deps: TwoFactorDeps) {
76
+ this.accounts = deps.accounts
77
+ this.twoFactor = deps.twoFactor
78
+ this.recoveryCodes = deps.recoveryCodes
79
+ this.sealingKey = deps.sealingKey
80
+ this.now = deps.clock ?? (() => new Date())
81
+ }
82
+
83
+ async state(userId: number): Promise<TwoFactorState> {
84
+ const record = await this.twoFactor.find(userId)
85
+ const enrolled = record !== null && record.confirmedAt !== null
86
+
87
+ return {
88
+ enrolled,
89
+ pending: record !== null && record.confirmedAt === null,
90
+ recoveryCodesLeft: enrolled ? await this.recoveryCodes.countUnused(userId) : 0,
91
+ }
92
+ }
93
+
94
+ async isEnrolled(userId: number): Promise<boolean> {
95
+ return holdsSecondFactor(this.twoFactor, userId)
96
+ }
97
+
98
+ async beginEnrolment(userId: number, boardName: string): Promise<Enrolment> {
99
+ const account = await this.accounts.findById(userId)
100
+ if (account === null) throw new NotFoundError(msg('error.accounts.account-longer-exists'))
101
+
102
+ const existing = await this.twoFactor.find(userId)
103
+ if (existing !== null && existing.confirmedAt !== null) {
104
+ throw new ConflictError(msg('error.accounts.account-already-asks-for-code'))
105
+ }
106
+
107
+ const secret = generateTotpSecret()
108
+ await this.twoFactor.startEnrolment({
109
+ userId,
110
+ sealedSecret: await sealSecret(secret, this.sealingKey),
111
+ now: this.now(),
112
+ })
113
+
114
+ return {
115
+ secret,
116
+ uri: otpauthUri({ secret, account: account.username, issuer: boardName }),
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The enrolment already under way, so the setup screen survives a reload
122
+ * without minting a second secret — which would strand whatever the member
123
+ * had already typed into their authenticator app.
124
+ */
125
+ async pendingEnrolment(userId: number, boardName: string): Promise<Enrolment | null> {
126
+ const record = await this.twoFactor.find(userId)
127
+ if (record === null || record.confirmedAt !== null) return null
128
+
129
+ const account = await this.accounts.findById(userId)
130
+ if (account === null) return null
131
+
132
+ const secret = await this.unseal(record.sealedSecret)
133
+ return {
134
+ secret,
135
+ uri: otpauthUri({ secret, account: account.username, issuer: boardName }),
136
+ }
137
+ }
138
+
139
+ async confirmEnrolment(input: {
140
+ readonly userId: number
141
+ readonly code: string
142
+ }): Promise<readonly string[]> {
143
+ const record = await this.twoFactor.find(input.userId)
144
+ if (record === null) {
145
+ throw new ConflictError(msg('error.accounts.start-setting-up-authenticator-app'))
146
+ }
147
+ if (record.confirmedAt !== null) {
148
+ throw new ConflictError(msg('error.accounts.account-already-asks-for-code-2'))
149
+ }
150
+
151
+ const secret = await this.unseal(record.sealedSecret)
152
+ const at = this.now()
153
+ const matched = await matchTotp({ secret, code: input.code, at })
154
+
155
+ if (matched === null) throw new ValidationError(WRONG_CODE)
156
+
157
+ await this.twoFactor.confirm(input.userId, matched.step, at)
158
+ return this.replaceRecoveryCodes(input.userId)
159
+ }
160
+
161
+ async verify(input: {
162
+ readonly userId: number
163
+ readonly code: string
164
+ }): Promise<SecondFactorOutcome> {
165
+ const record = await this.twoFactor.find(input.userId)
166
+ if (record === null || record.confirmedAt === null) return { status: 'wrong' }
167
+
168
+ const at = this.now()
169
+ const matched = await matchTotp({
170
+ secret: await this.unseal(record.sealedSecret),
171
+ code: input.code,
172
+ at,
173
+ })
174
+
175
+ if (matched !== null) {
176
+ const fresh = await this.twoFactor.spendStep(input.userId, matched.step)
177
+ return fresh ? { status: 'ok', usedRecoveryCode: false } : { status: 'replayed' }
178
+ }
179
+
180
+ const spent = await this.recoveryCodes.spend(
181
+ input.userId,
182
+ await hashToken(normaliseRecoveryCode(input.code)),
183
+ at,
184
+ )
185
+
186
+ return spent ? { status: 'ok', usedRecoveryCode: true } : { status: 'wrong' }
187
+ }
188
+
189
+ async replaceRecoveryCodes(userId: number): Promise<readonly string[]> {
190
+ const codes = Array.from({ length: RECOVERY_CODE_COUNT }, () => newRecoveryCode())
191
+
192
+ await this.recoveryCodes.replaceAll(
193
+ userId,
194
+ await Promise.all(codes.map((code) => hashToken(normaliseRecoveryCode(code)))),
195
+ this.now(),
196
+ )
197
+
198
+ return codes
199
+ }
200
+
201
+ async disable(input: { readonly userId: number; readonly required: boolean }): Promise<void> {
202
+ if (input.required) {
203
+ throw new ForbiddenError(msg('error.accounts.board-asks-its-staff-for'))
204
+ }
205
+
206
+ await this.twoFactor.remove(input.userId)
207
+ await this.recoveryCodes.removeAll(input.userId)
208
+ }
209
+
210
+ async abandonEnrolment(userId: number): Promise<void> {
211
+ const record = await this.twoFactor.find(userId)
212
+ if (record === null || record.confirmedAt !== null) return
213
+
214
+ await this.twoFactor.remove(userId)
215
+ }
216
+
217
+ private async unseal(sealed: string): Promise<string> {
218
+ const secret = await openSecret(sealed, this.sealingKey)
219
+ if (secret === null) {
220
+ throw new ForbiddenError(msg('error.accounts.board-read-secret-behind-authenticator'))
221
+ }
222
+ return secret
223
+ }
224
+ }
225
+
226
+ /**
227
+ * The yes-or-no the login path needs, straight off the repository. Built here
228
+ * rather than from the whole service so that composing the two does not hand
229
+ * the login path a key it has no use for.
230
+ */
231
+ export function enrolmentLookup(repository: TwoFactorRepository): {
232
+ isEnrolled(userId: number): Promise<boolean>
233
+ } {
234
+ return {
235
+ async isEnrolled(userId: number): Promise<boolean> {
236
+ const record = await repository.find(userId)
237
+ return record !== null && record.confirmedAt !== null
238
+ },
239
+ }
240
+ }
241
+
242
+ export function newRecoveryCode(): string {
243
+ const bytes = new Uint8Array(RECOVERY_CODE_BYTES)
244
+ crypto.getRandomValues(bytes)
245
+
246
+ const code = encodeBase32(bytes)
247
+ return code.match(/.{1,5}/g)?.join('-') ?? code
248
+ }
249
+
250
+ export function normaliseRecoveryCode(code: string): string {
251
+ return code.replace(/[\s-]/g, '').toUpperCase()
252
+ }
@@ -0,0 +1,120 @@
1
+ import { decodeBase32, encodeBase32 } from './base32'
2
+
3
+ export const TOTP_PERIOD_SECONDS = 30
4
+
5
+ export const TOTP_DIGITS = 6
6
+
7
+ export const TOTP_SECRET_BYTES = 20
8
+
9
+ /**
10
+ * How far either side of now a code is still taken. One step each way covers a
11
+ * device whose clock has drifted and a member who started typing at 29 seconds
12
+ * past; more than that widens the window an intercepted code stays usable in.
13
+ */
14
+ export const TOTP_SKEW_STEPS = 1
15
+
16
+ export function generateTotpSecret(byteLength = TOTP_SECRET_BYTES): string {
17
+ const bytes = new Uint8Array(byteLength)
18
+ crypto.getRandomValues(bytes)
19
+ return encodeBase32(bytes)
20
+ }
21
+
22
+ export function stepAt(at: Date, period = TOTP_PERIOD_SECONDS): number {
23
+ return Math.floor(at.getTime() / 1000 / period)
24
+ }
25
+
26
+ export async function totpCode(
27
+ secret: string,
28
+ step: number,
29
+ digits = TOTP_DIGITS,
30
+ ): Promise<string> {
31
+ const key = await crypto.subtle.importKey(
32
+ 'raw',
33
+ decodeBase32(secret) as unknown as BufferSource,
34
+ { name: 'HMAC', hash: 'SHA-1' },
35
+ false,
36
+ ['sign'],
37
+ )
38
+
39
+ const counter = new Uint8Array(8)
40
+ new DataView(counter.buffer).setBigUint64(0, BigInt(step))
41
+
42
+ const mac = new Uint8Array(
43
+ await crypto.subtle.sign('HMAC', key, counter as unknown as BufferSource),
44
+ )
45
+
46
+ const offset = mac[mac.length - 1]! & 0x0f
47
+ const binary =
48
+ ((mac[offset]! & 0x7f) << 24) |
49
+ (mac[offset + 1]! << 16) |
50
+ (mac[offset + 2]! << 8) |
51
+ mac[offset + 3]!
52
+
53
+ return String(binary % 10 ** digits).padStart(digits, '0')
54
+ }
55
+
56
+ export interface TotpMatch {
57
+ readonly step: number
58
+ }
59
+
60
+ /**
61
+ * The step the code belongs to, or null. The step is returned rather than a
62
+ * bare yes so the caller can refuse a code it has already accepted: a code is
63
+ * valid for thirty seconds, and anybody who reads it over a shoulder or off a
64
+ * proxy log has that long to use it first.
65
+ */
66
+ export async function matchTotp(input: {
67
+ readonly secret: string
68
+ readonly code: string
69
+ readonly at: Date
70
+ readonly skew?: number
71
+ readonly digits?: number
72
+ readonly period?: number
73
+ }): Promise<TotpMatch | null> {
74
+ const digits = input.digits ?? TOTP_DIGITS
75
+ const code = input.code.replace(/[\s-]/g, '')
76
+ if (!new RegExp(`^\\d{${digits}}$`).test(code)) return null
77
+
78
+ const skew = input.skew ?? TOTP_SKEW_STEPS
79
+ const centre = stepAt(input.at, input.period ?? TOTP_PERIOD_SECONDS)
80
+
81
+ for (let offset = -skew; offset <= skew; offset += 1) {
82
+ const step = centre + offset
83
+ if (step < 0) continue
84
+
85
+ if (timingSafeCompare(await totpCode(input.secret, step, digits), code)) {
86
+ return { step }
87
+ }
88
+ }
89
+
90
+ return null
91
+ }
92
+
93
+ export function otpauthUri(input: {
94
+ readonly secret: string
95
+ readonly account: string
96
+ readonly issuer: string
97
+ }): string {
98
+ const issuer = input.issuer.trim() === '' ? 'Meith' : input.issuer.trim()
99
+ const label = `${issuer}:${input.account}`
100
+
101
+ const parameters = new URLSearchParams({
102
+ secret: input.secret,
103
+ issuer,
104
+ algorithm: 'SHA1',
105
+ digits: String(TOTP_DIGITS),
106
+ period: String(TOTP_PERIOD_SECONDS),
107
+ })
108
+
109
+ return `otpauth://totp/${encodeURIComponent(label)}?${parameters.toString()}`
110
+ }
111
+
112
+ function timingSafeCompare(a: string, b: string): boolean {
113
+ if (a.length !== b.length) return false
114
+
115
+ let diff = 0
116
+ for (let index = 0; index < a.length; index += 1) {
117
+ diff |= a.charCodeAt(index) ^ b.charCodeAt(index)
118
+ }
119
+ return diff === 0
120
+ }