forked from passwordless-id/webauthn
-
Notifications
You must be signed in to change notification settings - Fork 0
/
server.ts
191 lines (145 loc) · 7.49 KB
/
server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
import { parseAuthentication, parseRegistration } from "./parsers";
import { AuthenticationEncoded, AuthenticationParsed, CredentialKey, NamedAlgo, RegistrationEncoded, RegistrationParsed } from "./types";
import * as utils from './utils'
async function isValid(validator :any, value :any) :Promise<boolean> {
if(typeof validator === 'function') {
const res = validator(value)
if(res instanceof Promise)
return await res
else
return res
}
// the validator can be a single value too
return validator === value
}
async function isNotValid(validator :any, value :any) :Promise<boolean> {
return !(await isValid(validator, value))
}
interface RegistrationChecks {
challenge: string | Function,
origin: string | Function
}
export async function verifyRegistration(registrationRaw: RegistrationEncoded, expected: RegistrationChecks): Promise<RegistrationParsed> {
const registration = parseRegistration(registrationRaw)
registration.client.challenge
if (registration.client.type !== "webauthn.create")
throw new Error(`Unexpected ClientData type: ${registration.client.type}`)
if (await isNotValid(expected.origin, registration.client.origin))
throw new Error(`Unexpected ClientData origin: ${registration.client.origin}`)
if (await isNotValid(expected.challenge, registration.client.challenge))
throw new Error(`Unexpected ClientData challenge: ${registration.client.challenge}`)
return registration
}
interface AuthenticationChecks {
challenge: string | Function,
origin: string | Function,
userVerified: boolean,
counter: number
}
export async function verifyAuthentication(authenticationRaw: AuthenticationEncoded, credential: CredentialKey, expected: AuthenticationChecks): Promise<AuthenticationParsed> {
if (authenticationRaw.credentialId !== credential.id)
throw new Error(`Credential ID mismatch: ${authenticationRaw.credentialId} vs ${credential.id}`)
const isValidSignature: boolean = await verifySignature({
algorithm: credential.algorithm,
publicKey: credential.publicKey,
authenticatorData: authenticationRaw.authenticatorData,
clientData: authenticationRaw.clientData,
signature: authenticationRaw.signature
})
if(!isValidSignature)
throw new Error(`Invalid signature: ${authenticationRaw.signature}`)
const authentication = parseAuthentication(authenticationRaw)
if (authentication.client.type !== "webauthn.get")
throw new Error(`Unexpected clientData type: ${authentication.client.type}`)
if (await isNotValid(expected.origin, authentication.client.origin))
throw new Error(`Unexpected ClientData origin: ${authentication.client.origin}`)
if (await isNotValid(expected.challenge, authentication.client.challenge))
throw new Error(`Unexpected ClientData challenge: ${authentication.client.challenge}`)
// this only works because we consider `rp.origin` and `rp.id` to be the same during authentication/registration
const rpId = new URL(authentication.client.origin).hostname
const expectedRpIdHash = utils.toBase64url(await utils.sha256(utils.toBuffer(rpId)))
if (authentication.authenticator.rpIdHash !== expectedRpIdHash)
throw new Error(`Unexpected RpIdHash: ${authentication.authenticator.rpIdHash} vs ${expectedRpIdHash}`)
if (!authentication.authenticator.flags.userPresent)
throw new Error(`Unexpected authenticator flags: missing userPresent`)
if (!authentication.authenticator.flags.userVerified && expected.userVerified)
throw new Error(`Unexpected authenticator flags: missing userVerified`)
if (authentication.authenticator.counter <= expected.counter)
throw new Error(`Unexpected authenticator counter: ${authentication.authenticator.counter} (should be > ${expected.counter})`)
return authentication
}
// https://w3c.github.io/webauthn/#sctn-public-key-easy
// https://www.iana.org/assignments/cose/cose.xhtml#algorithms
/*
User agents MUST be able to return a non-null value for getPublicKey() when the credential public key has a COSEAlgorithmIdentifier value of:
-7 (ES256), where kty is 2 (with uncompressed points) and crv is 1 (P-256).
-257 (RS256).
-8 (EdDSA), where crv is 6 (Ed25519).
*/
function getAlgoParams(algorithm: NamedAlgo): any {
switch (algorithm) {
case 'RS256':
return {
name: 'RSASSA-PKCS1-v1_5',
hash: 'SHA-256'
};
case 'ES256':
return {
name: 'ECDSA',
namedCurve: 'P-256',
hash: 'SHA-256',
};
// case 'EdDSA': Not supported by browsers
default:
throw new Error(`Unknown or unsupported crypto algorithm: ${algorithm}. Only 'RS256' and 'ES256' are supported.`)
}
}
type AlgoParams = AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm
async function parseCryptoKey(algoParams: AlgoParams, publicKey: string): Promise<CryptoKey> {
const buffer = utils.parseBase64url(publicKey)
return crypto.subtle.importKey('spki', buffer, algoParams, false, ['verify'])
}
type VerifyParams = {
algorithm: NamedAlgo,
publicKey: string, // Base64url encoded
authenticatorData: string, // Base64url encoded
clientData: string, // Base64url encoded
signature: string, // Base64url encoded
}
// https://w3c.github.io/webauthn/#sctn-verifying-assertion
// https://w3c.github.io/webauthn/#sctn-signature-attestation-types
/* Emphasis mine:
6.5.6. Signature Formats for Packed Attestation, FIDO U2F Attestation, and **Assertion Signatures**
[...] For COSEAlgorithmIdentifier -7 (ES256) [...] the sig value MUST be encoded as an ASN.1 [...]
[...] For COSEAlgorithmIdentifier -257 (RS256) [...] The signature is not ASN.1 wrapped.
[...] For COSEAlgorithmIdentifier -37 (PS256) [...] The signature is not ASN.1 wrapped.
*/
// see also https://gist.github.com/philholden/50120652bfe0498958fd5926694ba354
export async function verifySignature({ algorithm, publicKey, authenticatorData, clientData, signature }: VerifyParams): Promise<boolean> {
const algoParams = getAlgoParams(algorithm)
let cryptoKey = await parseCryptoKey(algoParams, publicKey)
console.debug(cryptoKey)
let clientHash = await utils.sha256(utils.parseBase64url(clientData));
// during "login", the authenticatorData is exactly 37 bytes
let comboBuffer = utils.concatenateBuffers(utils.parseBase64url(authenticatorData), clientHash)
console.debug('Crypto Algo: ' + JSON.stringify(algoParams))
console.debug('Public key: ' + publicKey)
console.debug('Data: ' + utils.toBase64url(comboBuffer))
console.debug('Signature: ' + signature)
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/verify
let signatureBuffer = utils.parseBase64url(signature)
if(algorithm == 'ES256')
signatureBuffer = convertASN1toRaw(signatureBuffer)
const isValid = await crypto.subtle.verify(algoParams, cryptoKey, signatureBuffer, comboBuffer)
return isValid
}
function convertASN1toRaw(signatureBuffer :ArrayBuffer) {
// Convert signature from ASN.1 sequence to "raw" format
const usignature = new Uint8Array(signatureBuffer);
const rStart = usignature[4] === 0 ? 5 : 4;
const rEnd = rStart + 32;
const sStart = usignature[rEnd + 2] === 0 ? rEnd + 3 : rEnd + 2;
const r = usignature.slice(rStart, rEnd);
const s = usignature.slice(sStart);
return new Uint8Array([...r, ...s]);
}