@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,266 @@
1
+ import { ConflictError, ForbiddenError, NotFoundError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { encodeBase64Url, randomBase64Url } from '../crypto/base64url'
5
+ import type {
6
+ AccountRecord,
7
+ AccountRepository,
8
+ Clock,
9
+ PasskeyRecord,
10
+ PasskeyRepository,
11
+ UserIdentityRepository,
12
+ } from '../ports'
13
+ import type { IdentityService, LoginResult, RequestContext } from '../service'
14
+ import { SUPPORTED_ALGORITHMS } from './cose'
15
+ import type { AssertionResponse, RegistrationResponse } from './verify'
16
+ import { type RelyingParty, verifyAssertion, verifyRegistration } from './verify'
17
+
18
+ export const PASSKEY_LABEL_MAX = 60
19
+
20
+ export const PASSKEY_LIMIT = 20
21
+
22
+ export const CHALLENGE_BYTES = 32
23
+
24
+ export const REMOVE_LAST_CREDENTIAL =
25
+ 'That is the only way you have left to sign in. Set a password first, then remove it.'
26
+
27
+ export interface PasskeyDeps {
28
+ readonly identity: IdentityService
29
+ readonly accounts: AccountRepository
30
+ readonly passkeys: PasskeyRepository
31
+ readonly identities?: UserIdentityRepository
32
+ readonly clock?: Clock
33
+ }
34
+
35
+ export interface PasskeyRegistrationOptions {
36
+ readonly challenge: string
37
+ readonly rp: { readonly id: string; readonly name: string }
38
+ readonly user: { readonly id: string; readonly name: string; readonly displayName: string }
39
+ readonly pubKeyCredParams: readonly { readonly type: 'public-key'; readonly alg: number }[]
40
+ readonly excludeCredentials: readonly {
41
+ readonly type: 'public-key'
42
+ readonly id: string
43
+ }[]
44
+ readonly authenticatorSelection: {
45
+ readonly residentKey: 'preferred'
46
+ readonly userVerification: 'preferred'
47
+ }
48
+ readonly timeout: number
49
+ readonly attestation: 'none'
50
+ }
51
+
52
+ export interface PasskeyAssertionOptions {
53
+ readonly challenge: string
54
+ readonly rpId: string
55
+ readonly userVerification: 'preferred'
56
+ readonly timeout: number
57
+ readonly allowCredentials?: readonly {
58
+ readonly type: 'public-key'
59
+ readonly id: string
60
+ }[]
61
+ }
62
+
63
+ const TIMEOUT_MS = 120_000
64
+
65
+ export function newChallenge(): string {
66
+ return randomBase64Url(CHALLENGE_BYTES)
67
+ }
68
+
69
+ export class PasskeyService {
70
+ private readonly identity: IdentityService
71
+ private readonly accounts: AccountRepository
72
+ private readonly passkeys: PasskeyRepository
73
+ private readonly identities: UserIdentityRepository | undefined
74
+ private readonly now: Clock
75
+
76
+ constructor(deps: PasskeyDeps) {
77
+ this.identity = deps.identity
78
+ this.accounts = deps.accounts
79
+ this.passkeys = deps.passkeys
80
+ this.identities = deps.identities
81
+ this.now = deps.clock ?? (() => new Date())
82
+ }
83
+
84
+ async registrationOptions(input: {
85
+ readonly userId: number
86
+ readonly challenge: string
87
+ readonly relyingParty: RelyingParty
88
+ readonly boardName: string
89
+ }): Promise<PasskeyRegistrationOptions> {
90
+ const account = await this.accounts.findById(input.userId)
91
+ if (account === null) throw new NotFoundError(msg('error.accounts.account-longer-exists'))
92
+
93
+ const existing = await this.passkeys.listForUser(input.userId)
94
+ if (existing.length >= PASSKEY_LIMIT) {
95
+ throw new ConflictError(
96
+ `An account may hold ${PASSKEY_LIMIT} passkeys. Remove one before adding another.`,
97
+ )
98
+ }
99
+
100
+ return {
101
+ challenge: input.challenge,
102
+ rp: { id: input.relyingParty.id, name: input.boardName },
103
+ user: {
104
+ id: userHandle(account.id),
105
+ name: account.username,
106
+ displayName: account.username,
107
+ },
108
+ pubKeyCredParams: SUPPORTED_ALGORITHMS.map((alg) => ({ type: 'public-key', alg })),
109
+ excludeCredentials: existing.map((passkey) => ({
110
+ type: 'public-key',
111
+ id: passkey.credentialId,
112
+ })),
113
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
114
+ timeout: TIMEOUT_MS,
115
+ attestation: 'none',
116
+ }
117
+ }
118
+
119
+ assertionOptions(input: {
120
+ readonly challenge: string
121
+ readonly relyingParty: RelyingParty
122
+ readonly allowCredentials?: readonly string[]
123
+ }): PasskeyAssertionOptions {
124
+ return {
125
+ challenge: input.challenge,
126
+ rpId: input.relyingParty.id,
127
+ userVerification: 'preferred',
128
+ timeout: TIMEOUT_MS,
129
+ ...(input.allowCredentials === undefined
130
+ ? {}
131
+ : {
132
+ allowCredentials: input.allowCredentials.map((id) => ({
133
+ type: 'public-key' as const,
134
+ id,
135
+ })),
136
+ }),
137
+ }
138
+ }
139
+
140
+ async enrol(input: {
141
+ readonly userId: number
142
+ readonly label: string
143
+ readonly response: RegistrationResponse
144
+ readonly expectedChallenge: string
145
+ readonly relyingParty: RelyingParty
146
+ }): Promise<PasskeyRecord> {
147
+ const verified = await verifyRegistration({
148
+ response: input.response,
149
+ expectedChallenge: input.expectedChallenge,
150
+ relyingParty: input.relyingParty,
151
+ })
152
+
153
+ const claimed = await this.passkeys.findByCredentialId(verified.credentialId)
154
+ if (claimed !== null) {
155
+ throw new ConflictError(msg('error.accounts.passkey-already-registered-here'))
156
+ }
157
+
158
+ return this.passkeys.create({
159
+ userId: input.userId,
160
+ credentialId: verified.credentialId,
161
+ publicKey: verified.publicKey,
162
+ signCount: verified.signCount,
163
+ label: passkeyLabel(input.label),
164
+ transports:
165
+ input.response.transports === undefined || input.response.transports.length === 0
166
+ ? null
167
+ : input.response.transports.join(','),
168
+ now: this.now(),
169
+ })
170
+ }
171
+
172
+ async authenticate(input: {
173
+ readonly credentialId: string
174
+ readonly response: AssertionResponse
175
+ readonly expectedChallenge: string
176
+ readonly relyingParty: RelyingParty
177
+ readonly context?: RequestContext
178
+ }): Promise<{ readonly account: AccountRecord; readonly login: LoginResult }> {
179
+ const passkey = await this.passkeys.findByCredentialId(input.credentialId)
180
+ if (passkey === null) {
181
+ throw new ForbiddenError(msg('error.accounts.passkey-registered-board'))
182
+ }
183
+
184
+ const verified = await verifyAssertion({
185
+ response: input.response,
186
+ expectedChallenge: input.expectedChallenge,
187
+ relyingParty: input.relyingParty,
188
+ publicKey: passkey.publicKey,
189
+ storedSignCount: passkey.signCount,
190
+ })
191
+
192
+ const account = await this.accounts.findById(passkey.userId)
193
+ if (account === null) {
194
+ throw new ForbiddenError(msg('error.accounts.account-behind-passkey-longer-exists'))
195
+ }
196
+
197
+ const login = await this.identity.startSessionFor(account, input.context ?? {})
198
+ await this.passkeys.markUsed(passkey.id, verified.signCount, this.now())
199
+
200
+ return { account, login }
201
+ }
202
+
203
+ /**
204
+ * The same signature check as signing in, against one named account and with
205
+ * no session at the end of it. A second factor has to prove the device
206
+ * belongs to the member who has just given their password — accepting any
207
+ * registered passkey would let anybody past anybody's second step.
208
+ */
209
+ async proveOwnership(input: {
210
+ readonly userId: number
211
+ readonly credentialId: string
212
+ readonly response: AssertionResponse
213
+ readonly expectedChallenge: string
214
+ readonly relyingParty: RelyingParty
215
+ }): Promise<boolean> {
216
+ const passkey = await this.passkeys.findByCredentialId(input.credentialId)
217
+ if (passkey === null || passkey.userId !== input.userId) return false
218
+
219
+ const verified = await verifyAssertion({
220
+ response: input.response,
221
+ expectedChallenge: input.expectedChallenge,
222
+ relyingParty: input.relyingParty,
223
+ publicKey: passkey.publicKey,
224
+ storedSignCount: passkey.signCount,
225
+ })
226
+
227
+ await this.passkeys.markUsed(passkey.id, verified.signCount, this.now())
228
+ return true
229
+ }
230
+
231
+ async remove(input: {
232
+ readonly userId: number
233
+ readonly passkeyId: number
234
+ readonly hasPassword: boolean
235
+ readonly usableProviders?: readonly string[]
236
+ }): Promise<void> {
237
+ const remaining = (await this.passkeys.listForUser(input.userId)).filter(
238
+ (passkey) => passkey.id !== input.passkeyId,
239
+ )
240
+
241
+ const usable = input.usableProviders ?? []
242
+ const links =
243
+ this.identities === undefined
244
+ ? []
245
+ : (await this.identities.listForUser(input.userId)).filter((identity) =>
246
+ usable.includes(identity.provider),
247
+ )
248
+
249
+ if (!input.hasPassword && remaining.length === 0 && links.length === 0) {
250
+ throw new ForbiddenError(REMOVE_LAST_CREDENTIAL)
251
+ }
252
+
253
+ const removed = await this.passkeys.remove(input.userId, input.passkeyId)
254
+ if (!removed) throw new NotFoundError(msg('error.accounts.passkey-already-removed'))
255
+ }
256
+ }
257
+
258
+ export function passkeyLabel(raw: string): string {
259
+ const trimmed = raw.replace(/\s+/gu, ' ').trim()
260
+ if (trimmed === '') return 'Passkey'
261
+ return [...trimmed].slice(0, PASSKEY_LABEL_MAX).join('')
262
+ }
263
+
264
+ function userHandle(userId: number): string {
265
+ return encodeBase64Url(new TextEncoder().encode(`u${userId}`))
266
+ }
@@ -0,0 +1,231 @@
1
+ import { ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { decodeBase64Url, encodeBase64Url } from '../crypto/base64url'
5
+ import { cborBytes, cborMap, decodeCbor } from './cbor'
6
+ import { importCoseKey, rawSignatureFromDer } from './cose'
7
+
8
+ const FLAG_USER_PRESENT = 0b0000_0001
9
+ const FLAG_USER_VERIFIED = 0b0000_0100
10
+ const FLAG_ATTESTED_CREDENTIAL = 0b0100_0000
11
+
12
+ export interface RelyingParty {
13
+ readonly id: string
14
+ readonly origin: string
15
+ }
16
+
17
+ export interface RegistrationResponse {
18
+ readonly clientDataJSON: string
19
+ readonly attestationObject: string
20
+ readonly transports?: readonly string[]
21
+ }
22
+
23
+ export interface RegisteredCredential {
24
+ readonly credentialId: string
25
+ readonly publicKey: string
26
+ readonly signCount: number
27
+ readonly userVerified: boolean
28
+ }
29
+
30
+ export interface AssertionResponse {
31
+ readonly clientDataJSON: string
32
+ readonly authenticatorData: string
33
+ readonly signature: string
34
+ }
35
+
36
+ const REFUSED = 'That passkey could not be verified. Try again.'
37
+
38
+ export async function verifyRegistration(input: {
39
+ readonly response: RegistrationResponse
40
+ readonly expectedChallenge: string
41
+ readonly relyingParty: RelyingParty
42
+ }): Promise<RegisteredCredential> {
43
+ assertClientData({
44
+ clientDataJSON: input.response.clientDataJSON,
45
+ expectedType: 'webauthn.create',
46
+ expectedChallenge: input.expectedChallenge,
47
+ expectedOrigin: input.relyingParty.origin,
48
+ })
49
+
50
+ const attestation = decodeOrRefuse(() =>
51
+ cborMap(decodeCbor(decodeBase64Url(input.response.attestationObject)).value),
52
+ )
53
+ const authData = decodeOrRefuse(() => cborBytes(attestation.get('authData')))
54
+ const parsed = await parseAuthenticatorData(authData, input.relyingParty.id)
55
+
56
+ if (!parsed.userPresent) {
57
+ throw new ValidationError(msg('error.accounts.passkey-confirmed-device'))
58
+ }
59
+
60
+ const credential = parsed.credential
61
+ if (credential === null) throw new ValidationError(REFUSED)
62
+
63
+ await decodeOrRefuseAsync(() => importCoseKey(credential.publicKey))
64
+
65
+ return {
66
+ credentialId: encodeBase64Url(credential.credentialId),
67
+ publicKey: encodeBase64Url(credential.publicKey),
68
+ signCount: parsed.signCount,
69
+ userVerified: parsed.userVerified,
70
+ }
71
+ }
72
+
73
+ export async function verifyAssertion(input: {
74
+ readonly response: AssertionResponse
75
+ readonly expectedChallenge: string
76
+ readonly relyingParty: RelyingParty
77
+ readonly publicKey: string
78
+ readonly storedSignCount: number
79
+ }): Promise<{ readonly signCount: number }> {
80
+ assertClientData({
81
+ clientDataJSON: input.response.clientDataJSON,
82
+ expectedType: 'webauthn.get',
83
+ expectedChallenge: input.expectedChallenge,
84
+ expectedOrigin: input.relyingParty.origin,
85
+ })
86
+
87
+ const authData = decodeBase64Url(input.response.authenticatorData)
88
+ const parsed = await parseAuthenticatorData(authData, input.relyingParty.id)
89
+
90
+ if (!parsed.userPresent) {
91
+ throw new ValidationError(msg('error.accounts.passkey-confirmed-device'))
92
+ }
93
+
94
+ const imported = await decodeOrRefuseAsync(() => importCoseKey(decodeBase64Url(input.publicKey)))
95
+
96
+ const clientDataHash = await sha256(decodeBase64Url(input.response.clientDataJSON))
97
+ const signed = concat(authData, clientDataHash)
98
+
99
+ const raw = decodeBase64Url(input.response.signature)
100
+ const signature = imported.derSignature ? rawSignatureFromDer(raw) : raw
101
+
102
+ const verified = await crypto.subtle.verify(
103
+ imported.verifyParams,
104
+ imported.key,
105
+ signature as unknown as BufferSource,
106
+ signed as unknown as BufferSource,
107
+ )
108
+
109
+ if (!verified) throw new ValidationError(REFUSED)
110
+
111
+ if (parsed.signCount > 0 && parsed.signCount <= input.storedSignCount) {
112
+ throw new ValidationError(msg('error.accounts.passkey-replayed-counter-board-already'))
113
+ }
114
+
115
+ return { signCount: parsed.signCount }
116
+ }
117
+
118
+ interface ParsedAuthenticatorData {
119
+ readonly userPresent: boolean
120
+ readonly userVerified: boolean
121
+ readonly signCount: number
122
+ readonly credential: {
123
+ readonly credentialId: Uint8Array
124
+ readonly publicKey: Uint8Array
125
+ } | null
126
+ }
127
+
128
+ async function parseAuthenticatorData(
129
+ authData: Uint8Array,
130
+ rpId: string,
131
+ ): Promise<ParsedAuthenticatorData> {
132
+ if (authData.length < 37) throw new ValidationError(REFUSED)
133
+
134
+ const expectedRpIdHash = await sha256(new TextEncoder().encode(rpId))
135
+ if (!sameBytes(authData.subarray(0, 32), expectedRpIdHash)) {
136
+ throw new ValidationError(msg('error.accounts.passkey-belongs-different-site'))
137
+ }
138
+
139
+ const flags = authData[32]!
140
+ const signCount = new DataView(authData.buffer, authData.byteOffset + 33, 4).getUint32(0)
141
+
142
+ if ((flags & FLAG_ATTESTED_CREDENTIAL) === 0) {
143
+ return {
144
+ userPresent: (flags & FLAG_USER_PRESENT) !== 0,
145
+ userVerified: (flags & FLAG_USER_VERIFIED) !== 0,
146
+ signCount,
147
+ credential: null,
148
+ }
149
+ }
150
+
151
+ const idLength = new DataView(authData.buffer, authData.byteOffset + 53, 2).getUint16(0)
152
+ const idStart = 55
153
+ const idEnd = idStart + idLength
154
+ if (idEnd > authData.length || idLength === 0) throw new ValidationError(REFUSED)
155
+
156
+ const keyBytes = authData.subarray(idEnd)
157
+ const key = decodeOrRefuse(() => decodeCbor(keyBytes))
158
+
159
+ return {
160
+ userPresent: (flags & FLAG_USER_PRESENT) !== 0,
161
+ userVerified: (flags & FLAG_USER_VERIFIED) !== 0,
162
+ signCount,
163
+ credential: {
164
+ credentialId: authData.slice(idStart, idEnd),
165
+ publicKey: keyBytes.slice(0, key.length),
166
+ },
167
+ }
168
+ }
169
+
170
+ function assertClientData(input: {
171
+ readonly clientDataJSON: string
172
+ readonly expectedType: string
173
+ readonly expectedChallenge: string
174
+ readonly expectedOrigin: string
175
+ }): void {
176
+ let data: Record<string, unknown>
177
+ try {
178
+ const decoded = new TextDecoder().decode(decodeBase64Url(input.clientDataJSON))
179
+ const parsed = JSON.parse(decoded) as unknown
180
+ if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
181
+ data = parsed as Record<string, unknown>
182
+ } catch (cause) {
183
+ throw new ValidationError(REFUSED, {}, { cause })
184
+ }
185
+
186
+ if (data.type !== input.expectedType) throw new ValidationError(REFUSED)
187
+
188
+ if (typeof data.challenge !== 'string' || data.challenge !== input.expectedChallenge) {
189
+ throw new ValidationError(msg('error.accounts.sign-in-attempt-expired-started-somewhere'))
190
+ }
191
+
192
+ if (typeof data.origin !== 'string' || data.origin !== input.expectedOrigin) {
193
+ throw new ValidationError(msg('error.accounts.passkey-used-against-different-address'))
194
+ }
195
+ }
196
+
197
+ function decodeOrRefuse<T>(read: () => T): T {
198
+ try {
199
+ return read()
200
+ } catch (cause) {
201
+ throw new ValidationError(REFUSED, {}, { cause })
202
+ }
203
+ }
204
+
205
+ async function decodeOrRefuseAsync<T>(read: () => Promise<T>): Promise<T> {
206
+ try {
207
+ return await read()
208
+ } catch (cause) {
209
+ throw new ValidationError(REFUSED, {}, { cause })
210
+ }
211
+ }
212
+
213
+ async function sha256(bytes: Uint8Array): Promise<Uint8Array> {
214
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as unknown as BufferSource))
215
+ }
216
+
217
+ function concat(left: Uint8Array, right: Uint8Array): Uint8Array {
218
+ const out = new Uint8Array(left.length + right.length)
219
+ out.set(left, 0)
220
+ out.set(right, left.length)
221
+ return out
222
+ }
223
+
224
+ function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
225
+ if (left.length !== right.length) return false
226
+ let diff = 0
227
+ for (let index = 0; index < left.length; index += 1) {
228
+ diff |= left[index]! ^ right[index]!
229
+ }
230
+ return diff === 0
231
+ }