11
22import * as b64u from "../../src/base64url" ;
3- import { StartRegistrationRequest , StartRegistrationResponse } from "../../src" ;
3+ import {
4+ FinishRegistrationRequest ,
5+ FinishRegistrationResponse ,
6+ StartRegistrationRequest ,
7+ StartRegistrationResponse
8+ } from "../../src" ;
49import { HttpError } from "./httpServer" ;
10+ import { Encoder } from "cbor-x" ;
511
612interface PendingEntry {
713 displayName : string ;
@@ -13,17 +19,15 @@ interface PendingEntry {
1319}
1420
1521/*
16- RegistrationService is a representation of a server side service that can start and finish
22+ RegistrationService is a server side service that can start and finish
1723client registrations.
1824Instantiate the service with a relying party id [rpId] and a list of allowed users.
1925A registration is started with a call to start which will produce a challenge that
20- the client must respond to when calling finish.
26+ the client must respond to with a call to finish.
2127 */
2228export class RegistrationService {
2329 // pending is the map of registrations which have been started; but not yet finished.
2430 // Maps challenge id to a PendingEntry structure.
25- // A registration.ts cannot be finished unless it has been successfully started.
26- // A successfully started registration.ts must appear on the pending map.
2731 private readonly pending : Map < string , PendingEntry > ;
2832 private readonly rpId : string ;
2933 private readonly allowedUsernames : string [ ] ;
@@ -34,7 +38,7 @@ export class RegistrationService {
3438 this . allowedUsernames = allowedUsernames ?? [ ] ;
3539 }
3640
37- start ( req : StartRegistrationRequest ) : StartRegistrationResponse {
41+ async start ( req : StartRegistrationRequest ) : Promise < StartRegistrationResponse > {
3842 if ( ! this . allowedUsernames . includes ( req . username ) ) {
3943 throw new HttpError ( 403 , { error : `username '${ req . username } ' not allowed` } ) ;
4044 }
@@ -48,11 +52,141 @@ export class RegistrationService {
4852 userId,
4953 challenge,
5054 challengeId,
51- rpId : this . rpId } ;
55+ rpId : this . rpId
56+ } ;
5257
53- this . pending . set ( challengeId , pendingEntry ) ; // save the requests which will be validated in finish
58+ // save the requests which will be validated in finish
59+ this . pending . set ( challengeId , pendingEntry ) ;
5460 return newStartRegistrationResponse ( pendingEntry )
5561 }
62+
63+ async finish ( req : FinishRegistrationRequest ) : Promise < FinishRegistrationResponse > {
64+ const entry = this . pending . get ( req . challengeId ) ;
65+ if ( ! entry ) {
66+ throw new HttpError ( 400 , { error : `unknown challengeId: ${ req . challengeId } ` } ) ;
67+ }
68+ if ( entry . username !== req . username ) {
69+ throw new HttpError ( 400 , { error : `username mismatch: ${ req . username } ` } ) ;
70+ }
71+ const clientData = parseClientData ( req . response . response . clientDataJSON ) ;
72+ if ( clientData . type !== "webauthn.create" ) {
73+ throw new HttpError ( 400 , { error : `wrong clientDataJSON type: ${ clientData . type } ` } ) ;
74+ }
75+ if ( clientData . challenge !== entry . challenge ) {
76+ throw new HttpError ( 400 , { error : "challenge mismatch" } ) ;
77+ }
78+
79+ const attestation = decodeAttestation ( req . response . response . attestationObject )
80+ const publicKey = await parsePublicKey ( attestation . publicKeyBytes )
81+ const isValid = await validateChallenge ( publicKey , attestation , req . response . response . clientDataJSON )
82+ if ( ! isValid ) {
83+ throw new HttpError ( 400 , { error : "invalid signature" } ) ;
84+ }
85+
86+ const credId = attestation . authData . slice ( 55 , 55 + attestation . credentialIdLength ) ;
87+ const flags = attestation . authData [ 32 ] ! ;
88+ this . pending . delete ( req . challengeId ) ;
89+ return {
90+ credential : {
91+ credentialId : b64u . encode ( credId ) ,
92+ userHandle : entry . userId ,
93+ label : req . label ?? "key" ,
94+ transports : req . response . response . transports ?? [ ] ,
95+ counter : attestation . dv . getUint32 ( 33 ) ,
96+ backupEligible : ( flags & 0x08 ) !== 0 ,
97+ backupState : ( flags & 0x10 ) !== 0 ,
98+ authenticatorData : b64u . encode ( attestation . authData ) ,
99+ } ,
100+ } ;
101+ }
102+ }
103+
104+ async function validateChallenge ( publicKey : CryptoKey , attestation : Attestation , clientDataJson : string ) : Promise < boolean > {
105+ const data = b64u . decode ( clientDataJson ) ;
106+ const clientDataHash = new Uint8Array (
107+ await crypto . subtle . digest ( "SHA-256" , data ) ,
108+ ) ;
109+ const verificationData = new Uint8Array ( attestation . authData . length + clientDataHash . length ) ;
110+ verificationData . set ( attestation . authData ) ;
111+ verificationData . set ( clientDataHash , attestation . authData . length ) ;
112+
113+ return crypto . subtle . verify (
114+ { name : "ECDSA" , hash : "SHA-256" } ,
115+ publicKey ,
116+ attestation . signature ,
117+ verificationData ,
118+ ) ;
119+ }
120+
121+ function parsePublicKey ( publicKeyBytes : Uint8Array < ArrayBuffer > ) : Promise < CryptoKey > {
122+ try {
123+ return crypto . subtle . importKey (
124+ "raw" ,
125+ publicKeyBytes ,
126+ { name : "ECDSA" , namedCurve : "P-256" } ,
127+ false ,
128+ [ "verify" ] ,
129+ ) ;
130+ } catch ( e ) {
131+ const eMessage = e as { message : string }
132+ const message = eMessage && eMessage . message ? eMessage . message : "unknown error" ;
133+ throw new HttpError ( 400 , { error : `invalid public key in authData: ${ message } ` } ) ;
134+ }
135+ }
136+
137+ type Attestation = {
138+ dv : DataView ,
139+ credentialIdLength : number ,
140+ signature : Uint8Array < ArrayBuffer > ,
141+ authData : Uint8Array < ArrayBufferLike > ,
142+ publicKeyBytes : Uint8Array < ArrayBuffer > ,
143+ }
144+
145+ function decodeAttestation ( attestation : string ) : Attestation {
146+ const cbor = new Encoder ( { useRecords : false , mapsAsObjects : false } ) ;
147+ const attestationObjectBytes = b64u . decode ( attestation ) ;
148+ const attObj = cbor . decode ( attestationObjectBytes ) as Map < string , unknown > ;
149+ if ( attObj . get ( "fmt" ) !== "packed" ) {
150+ throw new HttpError ( 400 , { error : `unsupported attestation format: ${ attObj . get ( "fmt" ) } ` } ) ;
151+ }
152+ const authData = attObj . get ( "authData" ) as Uint8Array ;
153+ const attStmt = attObj . get ( "attStmt" ) as Map < string , unknown > ;
154+ const sig = attStmt . get ( "sig" ) as Uint8Array < ArrayBuffer > ;
155+
156+ // Extract COSE public key from authData
157+ const dv = new DataView ( authData . buffer , authData . byteOffset ) ;
158+ const credIdLen = dv . getUint16 ( 53 ) ;
159+ const coseKey = cbor . decode ( authData . slice ( 55 + credIdLen ) ) as Map < number , unknown > ;
160+ const xBytes = coseKey . get ( - 2 ) as Uint8Array ;
161+ const yBytes = coseKey . get ( - 3 ) as Uint8Array ;
162+
163+ const rawPoint = new Uint8Array ( 65 ) ;
164+ rawPoint [ 0 ] = 0x04 ;
165+ rawPoint . set ( xBytes , 1 ) ;
166+ rawPoint . set ( yBytes , 33 ) ;
167+
168+ return {
169+ dv : dv ,
170+ credentialIdLength : credIdLen ,
171+ signature : sig ,
172+ authData : authData ,
173+ publicKeyBytes : rawPoint
174+ }
175+ }
176+
177+ function parseClientData ( clientDataJson : string ) : { type :string , challenge : string } {
178+ // Decode and validate clientDataJSON
179+ const data = b64u . decode ( clientDataJson ) ;
180+ try {
181+ return JSON . parse ( new TextDecoder ( ) . decode ( data ) ) as {
182+ type : string ;
183+ challenge : string ;
184+ } ;
185+ } catch ( e ) {
186+ const eMessage = e as { message : string }
187+ const message = eMessage && eMessage . message ? eMessage . message : "unknown error" ;
188+ throw new HttpError ( 400 , { error : "invalid client data json: " + message } ) ;
189+ }
56190}
57191
58192function newStartRegistrationResponse ( pendingEntry : PendingEntry ) : StartRegistrationResponse {
0 commit comments