@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.
- package/LICENSE.md +165 -0
- package/package.json +25 -0
- package/src/ban-filter.ts +127 -0
- package/src/ban-service.ts +71 -0
- package/src/case-fold.ts +3 -0
- package/src/credential-proof.ts +17 -0
- package/src/crypto/base64url.ts +53 -0
- package/src/crypto/legacy.ts +127 -0
- package/src/crypto/password.ts +92 -0
- package/src/crypto/tokens.ts +28 -0
- package/src/federation/catalog.ts +115 -0
- package/src/federation/github.ts +117 -0
- package/src/federation/http.ts +48 -0
- package/src/federation/jwt.ts +198 -0
- package/src/federation/oidc.ts +175 -0
- package/src/federation/pkce.ts +20 -0
- package/src/federation/service.ts +198 -0
- package/src/federation/types.ts +50 -0
- package/src/index.ts +223 -0
- package/src/member-settings.ts +390 -0
- package/src/memory-bans.ts +88 -0
- package/src/memory-repos.ts +665 -0
- package/src/policy.ts +104 -0
- package/src/ports.ts +415 -0
- package/src/register-fields.ts +17 -0
- package/src/service.ts +684 -0
- package/src/session-service.ts +134 -0
- package/src/test-support.fixture.ts +8 -0
- package/src/totp/base32.ts +52 -0
- package/src/totp/secret-box.ts +83 -0
- package/src/totp/service.ts +252 -0
- package/src/totp/totp.ts +120 -0
- package/src/webauthn/authenticator.fixture.ts +268 -0
- package/src/webauthn/cbor.ts +110 -0
- package/src/webauthn/cose.ts +130 -0
- package/src/webauthn/service.ts +266 -0
- package/src/webauthn/verify.ts +231 -0
|
@@ -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,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
|
+
}
|
package/src/totp/totp.ts
ADDED
|
@@ -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
|
+
}
|