I am implementing passwordless authentication in an application, communicating the WebAuthn API via LiveView JS hooks. Over the past few days I have cleared a few hurdles as I’ve learned about the API from webauthn.guide.
The JS hook is properly sending back the attestation and client data to the LiveView. I can decode the client data and compare the challenge and origin. I can also decode the CBOR attestation, but this is where I get stuck. It’s not clear what to do with attestation_a
and attestation_b
.
The WebAuthn guide shows authData
, ftm
, and attStmt
keys once the CBOR is decoded. I looked into decoding on the client, but that seemed to be a bad idea. The docs for Wax and CBOR have been helpful up to this point, and I’ve even dug into source code a bit. However, I’m not sure if the CBOR is being decoded correctly or what I need to do next to get authData
and other attestation data.
If you have suggestions or examples, I would appreciate it.
Decoded Attestation
attestation_a: %{0 => <<3, 0, 116, 4, 0, 100, _REDACTED_>>, <<1, 0, 102>> => 2}
attestation_b: <<0, 110, 8, 0, 101, 9, 0, 103, _REDACTED_, 18, 0, 104, 19, 0, 97, 20, 0,
117, 21, 0, 116, 22, 0, 104, 23, 0, 68, ...>>
LiveView Credentials Event Handler
def handle_event(
"credentials",
%{"attestation" => attestation, "clientData" => client_data, "type" => _type},
%{assigns: %{challenge: server_challenge}} = socket
) do
%{"challenge" => client_challenge, "origin" => client_origin} =
client_data
|> List.to_string()
|> Jason.decode!()
|> Map.update!("challenge", &Base.decode64!(&1, padding: false))
# TODO: DECODE ATTESTATION
{:ok, attestation_a, attestation_b} =
attestation
|> Base.decode64!()
|> CBOR.decode()
IO.inspect(attestation_a, label: "attestation_a")
IO.inspect(attestation_b, label: "attestation_b")
with true <- client_challenge == server_challenge,
true <- client_origin == LiveShowyWeb.Endpoint.url() do
# TODO: STORE PUB KEY AFTER VALIDATION PASSES
# TODO: REDIRECT TO REFERRER OR HOME AFTER STORING PUB KEY
{:noreply, put_flash(socket, :info, "Registration was successful")}
else
_ ->
{:noreply, put_flash(socket, :error, "Registration failed")}
end
end
HandleWebAuthn Hook
const HandleWebAuthn = {
mounted() {
console.info(`HandleWebAuthn mounted`)
if (navigator.credentials) {
console.info(`WebAuthn is supported by this browser.`)
this.pushEvent("webauthn-supported", true)
window.addEventListener("phx:challenge", async (data) => {
const { appName, challenge, user } = data.detail
const publicKey = {
challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)),
rp: {
name: appName,
id: document.location.host,
},
user: {
id: Uint8Array.from(user.id, c => c.charCodeAt(0)),
name: user.email,
displayName: user.username
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
timeout: 60000,
attestation: "none",
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "discouraged",
},
}
const { response, type } = await navigator.credentials.create({ publicKey })
const { attestationObject, clientDataJSON } = response
const clientData = Array.from(new Uint8Array(clientDataJSON))
const attestation = Array.from(new Uint8Array(attestationObject))
.map(String.fromCharCode).join("")
this.pushEvent("credentials", {attestation: btoa(attestation), clientData, type})
})
} else {
console.error(`WebAuthn is not supported by this browser.`)
this.pushEvent("webauthn-supported", false)
}
}
}