Skip to content

Commit

Permalink
chore(passport): Passkeys userHandle generation redo (#2708)
Browse files Browse the repository at this point in the history
  • Loading branch information
betimshahini committed Oct 6, 2023
1 parent a545db5 commit f43d794
Show file tree
Hide file tree
Showing 3 changed files with 66 additions and 39 deletions.
42 changes: 26 additions & 16 deletions apps/passport/app/routes/authenticate/$clientId/webauthn/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type ActionFunction,
type LoaderFunction,
} from '@remix-run/cloudflare'
import { InternalServerError } from '@proofzero/errors'
import { BadRequestError, InternalServerError } from '@proofzero/errors'
import { AccountURNSpace } from '@proofzero/urns/account'
import { generateHashedIDRef } from '@proofzero/urns/idref'
import { NodeType, WebauthnAccountType } from '@proofzero/types/account'
Expand Down Expand Up @@ -56,7 +56,9 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
signature: formdata.get('signature') as string,
rawId: formdata.get('rawId') as string,
}

const decodedUserHandle = new TextDecoder().decode(
base64url.decode(loginPayload.userHandle)
)
const clientDataJSON = new TextDecoder().decode(
base64url.decode(loginPayload.clientDataJSON)
)
Expand All @@ -70,25 +72,31 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
await verifySignedWebauthnChallenge(challenge, webauthnChallengeJwks)

const accountURN = AccountURNSpace.componentizedUrn(
generateHashedIDRef(
WebauthnAccountType.WebAuthN,
loginPayload.credentialId
),
generateHashedIDRef(WebauthnAccountType.WebAuthN, decodedUserHandle),
{ node_type: NodeType.WebAuthN, addr_type: WebauthnAccountType.WebAuthN },
{ alias: loginPayload.credentialId }
{ alias: decodedUserHandle }
)

const coreClient = getCoreClient({ context, accountURN })

const webAuthnData = await coreClient.account.getWebAuthNData.query()

if (!webAuthnData || webAuthnData.counter === undefined || webAuthnData.publicKey === undefined)
if (
!webAuthnData ||
webAuthnData.counter === undefined ||
webAuthnData.publicKey === undefined
)
throw new InternalServerError({
message:
'Could not retrieve passkey verification data. Try again or register new key.',
})

const passportUrl = new URL(request.url)
if (webAuthnData.credentialId !== loginPayload.credentialId)
throw new BadRequestError({
message: `The browser's authenticator returned data we cannot verify. Please try again or use another browser.`,
})

const f2l = new Fido2Lib({
timeout: webauthnConstants.timeout,
rpId: passportUrl.hostname,
Expand Down Expand Up @@ -263,14 +271,16 @@ export default () => {
</Text>
</section>
{!webauthnSupported && (
<section><Text
size="sm"
weight="medium"
className="text-red-500 mt-4 mb-2 text-center"
>
Your browser does not support Passkeys. Please change your security settings or try another browser.
</Text></section>

<section>
<Text
size="sm"
weight="medium"
className="text-red-500 mt-4 mb-2 text-center"
>
Your browser does not support Passkeys. Please change your
security settings or try another browser.
</Text>
</section>
)}

<section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ import subtractLogo from '~/assets/subtract-logo.svg'
import {
createSignedWebauthnChallenge,
verifySignedWebauthnChallenge,
webauthnConstants
webauthnConstants,
} from './utils'
import { BadRequestError } from '@proofzero/errors'
import { KeyPairSerialized } from '@proofzero/packages/types/application'
import { toast, ToastType } from '@proofzero/design-system/src/atoms/toast'
import generateRandomString from '@proofzero/utils/generateRandomString'

type RegistrationPayload = {
nickname: string
Expand Down Expand Up @@ -55,11 +56,16 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper(
const webauthnChallengeJwks = JSON.parse(
context.env.SECRET_WEBAUTHN_SIGNING_KEY
) as KeyPairSerialized
const userHandle = `wa_${generateRandomString(
webauthnConstants.credentialIdLength
)}`

const challengeJwt = await createSignedWebauthnChallenge(
webauthnChallengeJwks
webauthnChallengeJwks,
userHandle
)
registrationOptions.challenge = challengeJwt
return json({ registrationOptions })
return json({ registrationOptions, userHandle })
}
)

Expand Down Expand Up @@ -93,17 +99,25 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
const webauthnChallengeJwks = JSON.parse(
context.env.SECRET_WEBAUTHN_SIGNING_KEY
) as KeyPairSerialized
await verifySignedWebauthnChallenge(challenge, webauthnChallengeJwks)
const userHandle = await verifySignedWebauthnChallenge(
challenge,
webauthnChallengeJwks
)
if (!userHandle)
throw new BadRequestError({
message:
'Invalid data received from the browser. Try again or use another browser.',
})

const passportUrl = new URL(request.url)

const f2l = new Fido2Lib({
timeout: 42,
timeout: webauthnConstants.timeout,
rpId: passportUrl.hostname,
rpName: 'Rollup ID',
challengeSize: 200,
challengeSize: webauthnConstants.challengeSize,
attestation: 'none',
cryptoParams: [-7, -257],
cryptoParams: webauthnConstants.cryptoAlgsArray,
authenticatorRequireResidentKey: false,
authenticatorUserVerification: 'required',
})
Expand All @@ -127,15 +141,13 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
const webauthnData = {
counter: registrationResults.authnrData.get('counter'),
publicKey: registrationResults.authnrData.get('credentialPublicKeyPem'),
credentialId: registrationPayload.credentialId,
}

const accountURN = AccountURNSpace.componentizedUrn(
generateHashedIDRef(
WebauthnAccountType.WebAuthN,
registrationPayload.credentialId
),
generateHashedIDRef(WebauthnAccountType.WebAuthN, userHandle),
{ node_type: NodeType.WebAuthN, addr_type: WebauthnAccountType.WebAuthN },
{ alias: registrationPayload.credentialId }
{ alias: userHandle }
)

const appData = await getAuthzCookieParams(request, context.env)
Expand Down Expand Up @@ -169,7 +181,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
)

export default () => {
const { registrationOptions } = useLoaderData()
const { registrationOptions, userHandle } = useLoaderData()
if (
registrationOptions?.challenge &&
typeof registrationOptions.challenge === 'string'
Expand All @@ -185,11 +197,9 @@ export default () => {

const webauthnSupported = !!window.PublicKeyCredential

const randomBuffer = new Uint8Array(32)
crypto.getRandomValues(randomBuffer)
const registerKey = async (name: string) => {
registrationOptions.user = {
id: new TextEncoder().encode(base64url.encode(randomBuffer)),
id: new TextEncoder().encode(userHandle),
name,
displayName: name,
}
Expand All @@ -199,7 +209,7 @@ export default () => {
publicKey: registrationOptions,
})
} catch (e) {
console.error("Passkey registration error", JSON.stringify(e, null, 2))
console.error('Passkey registration error', JSON.stringify(e, null, 2))
if (e instanceof DOMException && e.name === 'NotAllowedError') {
toast(ToastType.Error, {
message:
Expand Down
19 changes: 13 additions & 6 deletions apps/passport/app/routes/authenticate/$clientId/webauthn/utils.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
import { BadRequestError, InternalServerError } from '@proofzero/errors'
import { KeyPairSerialized } from '@proofzero/packages/types/application'
import { SignJWT, importJWK, jwtVerify, errors } from 'jose'
import { SignJWT, importJWK, jwtVerify, errors, decodeJwt } from 'jose'

export const createSignedWebauthnChallenge = async (
keyPairJSON: KeyPairSerialized
keyPairJSON: KeyPairSerialized,
userId?: string
) => {
const privateKey = await importJWK(keyPairJSON.privateKey)
const challengeRandomBuffer = new Uint8Array(48)
crypto.getRandomValues(challengeRandomBuffer)
const payload = { challenge: challengeRandomBuffer }
const challengeJwt = await new SignJWT(payload)
const signableJwt = await new SignJWT(payload)
.setProtectedHeader({ alg: 'ES256' })
.setExpirationTime('5 min')
.sign(privateKey)

if (userId) signableJwt.setSubject(userId)

const challengeJwt = signableJwt.sign(privateKey)
return challengeJwt
}

export const verifySignedWebauthnChallenge = async (
challengeJwt: string,
keyPairJSON: KeyPairSerialized
) => {
): Promise<string | undefined> => {
const publicKey = await importJWK(keyPairJSON.publicKey)
try {
await jwtVerify(challengeJwt, publicKey)
const jwt = decodeJwt(challengeJwt)
return jwt.sub
} catch (e) {
if (e instanceof errors.JWTExpired)
throw new BadRequestError({
Expand All @@ -37,7 +43,8 @@ export const verifySignedWebauthnChallenge = async (
}

export const webauthnConstants = {
challengeSize: 200,
challengeSize: 250,
timeout: 60,
cryptoAlgsArray: [-7, -8, -257],
credentialIdLength: 42,
}

0 comments on commit f43d794

Please sign in to comment.