@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,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
|
+
}
|