From eea6819462bb877012593a36caee86e9448b9481 Mon Sep 17 00:00:00 2001 From: Vitalii Martyniak Date: Sun, 28 Jul 2024 09:53:51 +0200 Subject: [PATCH 1/7] Add webauthn auth provider UI components --- src/auth/ha-auth-flow.ts | 93 +++++- src/data/data_entry_flow.ts | 4 +- src/data/webauthn.ts | 195 +++++++++++ .../profile/ha-profile-section-security.ts | 23 ++ src/panels/profile/ha-setup-passkey-card.ts | 308 ++++++++++++++++++ src/translations/en.json | 36 ++ 6 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 src/data/webauthn.ts create mode 100644 src/panels/profile/ha-setup-passkey-card.ts diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index 668beb4685a6..34691721e2a6 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -17,6 +17,12 @@ import { redirectWithAuthCode, submitLoginFlow, } from "../data/auth"; +import type { + PublicKeyCredentialRequestOptions, + PublicKeyCredentialRequestOptionsJSON, + AuthenticationCredentialJSON, + AuthenticationCredential, +} from "../data/webauthn"; import type { DataEntryFlowStep, DataEntryFlowStepForm, @@ -216,7 +222,7 @@ export class HaAuthFlow extends LitElement { `ui.panel.page-authorize.form.providers.${step.handler[0]}.abort.${step.reason}` )} `; - case "form": + case "form": { return html`

${!["select_mfa_module", "mfa"].includes(step.step_id) @@ -262,11 +268,30 @@ export class HaAuthFlow extends LitElement { ` : ""} `; + } default: return nothing; } } + private _base64url = { + encode: function (buffer) { + const base64 = window.btoa( + String.fromCharCode(...new Uint8Array(buffer)) + ); + return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + }, + decode: function (base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const binStr = window.atob(base64); + const bin = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + bin[i] = binStr.charCodeAt(i); + } + return bin.buffer; + }, + }; + private _storeTokenChanged(e: CustomEvent) { this._storeToken = (e.currentTarget as HTMLInputElement).checked; } @@ -373,6 +398,12 @@ export class HaAuthFlow extends LitElement { const newStep = await response.json(); + if (newStep.step_id === "challenge" && newStep.type === "form") { + const publicKeyOptions = + newStep.description_placeholders!.webauthn_options; + this._getWebauthnCredentials(publicKeyOptions); + } + if (response.status === 403) { this._state = "error"; this._errorMessage = newStep.message; @@ -399,6 +430,66 @@ export class HaAuthFlow extends LitElement { this._submitting = false; } } + + private async _getWebauthnCredentials( + publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptionsJSON + ) { + const publicKeyOptions: PublicKeyCredentialRequestOptions = { + ...publicKeyCredentialRequestOptions, + challenge: this._base64url.decode( + publicKeyCredentialRequestOptions.challenge + ), + allowCredentials: publicKeyCredentialRequestOptions.allowCredentials.map( + (cred) => ({ + ...cred, + id: this._base64url.decode(cred.id), + }) + ), + }; + try { + const result = await navigator.credentials.get({ + publicKey: publicKeyOptions, + }); + const authenticationCredential = result as AuthenticationCredential; + const authenticationCredentialJSON: AuthenticationCredentialJSON = { + id: authenticationCredential.id, + authenticatorAttachment: + authenticationCredential.authenticatorAttachment, + rawId: this._base64url.encode(authenticationCredential.rawId), + response: { + userHandle: this._base64url.encode( + authenticationCredential.response.userHandle + ), + clientDataJSON: this._base64url.encode( + authenticationCredential.response.clientDataJSON + ), + authenticatorData: this._base64url.encode( + authenticationCredential.response.authenticatorData + ), + signature: this._base64url.encode( + authenticationCredential.response.signature + ), + }, + type: authenticationCredential.type, + }; + this._stepData = { + authentication_credential: authenticationCredentialJSON, + client_id: this.clientId, + }; + this._handleSubmit(new Event("submit")); + } catch (err: any) { + // eslint-disable-next-line no-console + if (err instanceof DOMException) { + this._errorMessage = "WebAuthn operation was aborted."; + } else { + this._errorMessage = + "An unexpected error occurred during WebAuthn authentication."; + } + this._state = "error"; + } finally { + this._submitting = false; + } + } } declare global { diff --git a/src/data/data_entry_flow.ts b/src/data/data_entry_flow.ts index 33b49424e33a..eedd64cffefa 100644 --- a/src/data/data_entry_flow.ts +++ b/src/data/data_entry_flow.ts @@ -1,6 +1,7 @@ import type { Connection } from "home-assistant-js-websocket"; import type { HaFormSchema } from "../components/ha-form/types"; import type { ConfigEntry } from "./config_entries"; +import type { DataEntryFlowStepChallengeForm } from "./webauthn"; export type FlowType = "config_flow" | "options_flow" | "repair_flow"; @@ -94,7 +95,8 @@ export type DataEntryFlowStep = | DataEntryFlowStepCreateEntry | DataEntryFlowStepAbort | DataEntryFlowStepProgress - | DataEntryFlowStepMenu; + | DataEntryFlowStepMenu + | DataEntryFlowStepChallengeForm; export const subscribeDataEntryFlowProgressed = ( conn: Connection, diff --git a/src/data/webauthn.ts b/src/data/webauthn.ts new file mode 100644 index 000000000000..042b2c96ba72 --- /dev/null +++ b/src/data/webauthn.ts @@ -0,0 +1,195 @@ +import type { HaFormSchema } from "../components/ha-form/types"; + +declare global { + interface HASSDomEvents { + "hass-refresh-passkeys": undefined; + } +} + +export interface Passkey { + id: string; + credential_id: string; + name: string; + created_at: string; + last_used_at?: string; +} + +export interface PublicKeyCredentialCreationOptionsJSON { + rp: PublicKeyCredentialRpEntity; + user: PublicKeyCredentialUserEntityJSON; + challenge: string; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout?: number; + excludeCredentials: PublicKeyCredentialDescriptorJSON[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + attestation?: AttestationConveyancePreference; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialUserEntityJSON { + id: string; + name: string; + displayName: string; +} + +export interface PublicKeyCredentialDescriptorJSON { + type: PublicKeyCredentialType; + id: string; + transports?: AuthenticatorTransport[]; +} + +export interface PublicKeyCredentialCreationOptions { + rp: PublicKeyCredentialRpEntity; + user: PublicKeyCredentialUserEntity; + challenge: BufferSource; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout?: number; + excludeCredentials?: PublicKeyCredentialDescriptor[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + attestation?: AttestationConveyancePreference; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialRequestOptionsJSON { + id: string; + challenge: string; + timeout?: number; + rpId?: string; + allowCredentials: PublicKeyCredentialDescriptorJSON[]; + userVerification?: UserVerificationRequirement; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialRequestOptions { + id: string; + challenge: BufferSource; + timeout?: number; + rpId?: string; + allowCredentials?: PublicKeyCredentialDescriptor[]; + userVerification?: UserVerificationRequirement; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialRpEntity { + id: string; + name: string; +} + +export interface PublicKeyCredentialUserEntity { + id: BufferSource; + name: string; + displayName: string; +} + +export interface PublicKeyCredentialParameters { + type: PublicKeyCredentialType; + alg: COSEAlgorithmIdentifier; +} + +export type PublicKeyCredentialType = "public-key"; + +export type COSEAlgorithmIdentifier = -7 | -257 | -65535 | -257 | -65535; + +export interface PublicKeyCredentialDescriptor { + type: PublicKeyCredentialType; + id: BufferSource; + transports?: AuthenticatorTransport[]; +} + +export type AuthenticatorTransport = "usb" | "nfc" | "ble" | "internal"; + +export type AuthenticatorAttachment = "platform" | "cross-platform"; + +export type UserVerificationRequirement = + | "required" + | "preferred" + | "discouraged"; + +export interface AuthenticatorSelectionCriteria { + authenticatorAttachment?: AuthenticatorAttachment; + requireResidentKey?: boolean; + userVerification?: UserVerificationRequirement; +} + +export type AttestationConveyancePreference = "none" | "indirect" | "direct"; + +export interface AuthenticationExtensionsClientInputs { + [key: string]: any; +} + +export interface PublicKeyCredentialWithClientExtensionOutputs { + clientExtensionResults: AuthenticationExtensionsClientOutputs; +} + +export interface AuthenticationExtensionsClientOutputs { + [key: string]: any; +} + +export interface PublicKeyCredentialAttestationResponse { + clientDataJSON: BufferSource; + attestationObject: BufferSource; +} + +export interface PublicKeyCredentialAssertionResponse { + clientDataJSON: BufferSource; + authenticatorData: BufferSource; + signature: BufferSource; + userHandle: BufferSource; +} + +export interface PublicKeyRegistartionCredentialResponseJSON { + authenticatorAttachment: string; + id: string; + rawId: string; + response: PublicKeyCredentialAttestationResponseJSON; + type: string; +} + +export interface PublicKeyCredentialAttestationResponseJSON { + clientDataJSON: string; + attestationObject: string; +} + +export interface PublicKeyRegistartionCredentialResponse { + authenticatorAttachment: string; + id: string; + rawId: BufferSource; + response: PublicKeyCredentialAttestationResponse; + type: string; +} + +export interface AuthenticationCredentialJSON { + authenticatorAttachment: string; + id: string; + rawId: string; + response: PublicKeyCredentialAssertionResponseJSON; + type: string; +} + +export interface PublicKeyCredentialAssertionResponseJSON { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string; +} + +export interface AuthenticationCredential { + authenticatorAttachment: string; + id: string; + rawId: BufferSource; + response: PublicKeyCredentialAssertionResponse; + type: string; +} + +export interface DataEntryFlowStepChallengeForm { + type: "form"; + flow_id: string; + handler: string; + step_id: string; + data_schema: HaFormSchema[]; + errors: Record; + description_placeholders?: Record; + last_step: boolean | null; + preview?: string; + translation_domain?: string; +} diff --git a/src/panels/profile/ha-profile-section-security.ts b/src/panels/profile/ha-profile-section-security.ts index 83b2043138ea..cf5f9eb91b0f 100644 --- a/src/panels/profile/ha-profile-section-security.ts +++ b/src/panels/profile/ha-profile-section-security.ts @@ -10,6 +10,8 @@ import "./ha-change-password-card"; import "./ha-long-lived-access-tokens-card"; import "./ha-mfa-modules-card"; import "./ha-refresh-tokens-card"; +import "./ha-setup-passkey-card"; +import { Passkey } from "../../data/webauthn"; @customElement("ha-profile-section-security") class HaProfileSectionSecurity extends LitElement { @@ -19,6 +21,8 @@ class HaProfileSectionSecurity extends LitElement { @state() private _refreshTokens?: RefreshToken[]; + @state() private _passkeys?: Passkey[]; + @property({ attribute: false }) public route!: Route; public connectedCallback() { @@ -30,6 +34,9 @@ class HaProfileSectionSecurity extends LitElement { if (!this._refreshTokens) { this._refreshRefreshTokens(); } + if (!this._passkeys) { + this._refreshPasskeys(); + } } protected render(): TemplateResult { @@ -54,6 +61,13 @@ class HaProfileSectionSecurity extends LitElement { > ` : ""} + + + ({ + type: "config/auth_provider/passkey/list", + }); + } + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/profile/ha-setup-passkey-card.ts b/src/panels/profile/ha-setup-passkey-card.ts new file mode 100644 index 000000000000..37cf1bc23cb0 --- /dev/null +++ b/src/panels/profile/ha-setup-passkey-card.ts @@ -0,0 +1,308 @@ +import "@material/mwc-button"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { mdiKey, mdiDotsVertical, mdiDelete, mdiRename } from "@mdi/js"; +import { customElement, property } from "lit/decorators"; +import { ActionDetail } from "@material/mwc-list"; +import { relativeTime } from "../../common/datetime/relative_time"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-card"; +import "../../components/ha-circular-progress"; +import "../../components/ha-textfield"; +import { + Passkey, + PublicKeyCredentialCreationOptions, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyRegistartionCredentialResponse, + PublicKeyRegistartionCredentialResponseJSON, +} from "../../data/webauthn"; +import { haStyle } from "../../resources/styles"; +import type { HomeAssistant } from "../../types"; +import "../../components/ha-alert"; +import { + showAlertDialog, + showConfirmationDialog, + showPromptDialog, +} from "../../dialogs/generic/show-dialog-box"; + +@customElement("ha-setup-passkey-card") +class HaSetupPasskeyCard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public passkeys!: Passkey[]; + + protected render(): TemplateResult { + return html` + +
+ ${this.hass.localize("ui.panel.profile.passkeys.description")} + ${!this.passkeys?.length + ? html`

+ ${this.hass.localize("ui.panel.profile.passkeys.empty_state")} +

` + : this.passkeys.map( + (passkey) => html` + + + ${passkey.name} +
+ ${this.hass.localize( + "ui.panel.profile.passkeys.created_at", + { + date: relativeTime( + new Date(passkey.created_at), + this.hass.locale + ), + } + )} +
+
+ ${passkey.last_used_at + ? this.hass.localize( + "ui.panel.profile.passkeys.last_used", + { + date: relativeTime( + new Date(passkey.last_used_at), + this.hass.locale + ), + } + ) + : this.hass.localize( + "ui.panel.profile.passkeys.not_used" + )} +
+
+ + + + + ${this.hass.localize("ui.common.rename")} + + + + ${this.hass.localize("ui.common.delete")} + + +
+
+ ` + )} +
+ +
+ ${this.hass.localize("ui.common.add")} +
+
+ `; + } + + private async _handleAction(ev: CustomEvent) { + const passkey = (ev.currentTarget as any).passkey; + switch (ev.detail.index) { + case 0: + this._renamePasskey(passkey); + break; + case 1: + this._deletePasskey(passkey); + break; + } + } + + private async _renamePasskey(passkey: Passkey): Promise { + const newName = await showPromptDialog(this, { + text: this.hass.localize("ui.panel.profile.passkeys.prompt_name"), + inputLabel: this.hass.localize("ui.panel.profile.passkeys.name"), + }); + if (!newName || newName === passkey.name) { + return; + } + try { + await this.hass.callWS({ + type: "config/auth_provider/passkey/rename", + credential_id: passkey.credential_id, + name: newName, + }); + fireEvent(this, "hass-refresh-passkeys"); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.rename_failed"), + text: err.message, + }); + } + } + + private async _deletePasskey(passkey: Passkey): Promise { + if ( + !(await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.profile.passkeys.confirm_delete_title" + ), + text: this.hass.localize( + "ui.panel.profile.passkeys.confirm_delete_text", + { name: passkey.name } + ), + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + })) + ) { + return; + } + try { + await this.hass.callWS({ + type: "config/auth_provider/passkey/delete", + credential_id: passkey.credential_id, + }); + fireEvent(this, "hass-refresh-passkeys"); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.delete_failed"), + text: err.message, + }); + } + } + + private async _registerPasskey() { + try { + const registrationOptions: PublicKeyCredentialCreationOptionsJSON = + await this.hass.callWS({ + type: "config/auth_provider/passkey/register", + }); + const options: PublicKeyCredentialCreationOptions = { + ...registrationOptions, + user: { + ...registrationOptions.user, + id: this._base64url.decode(registrationOptions.user.id), + }, + challenge: this._base64url.decode(registrationOptions.challenge), + excludeCredentials: registrationOptions.excludeCredentials.map( + (cred) => ({ + ...cred, + id: this._base64url.decode(cred.id), + }) + ), + }; + + const result = await navigator.credentials.create({ publicKey: options }); + const publicKeyCredential = + result as PublicKeyRegistartionCredentialResponse; + const credentials: PublicKeyRegistartionCredentialResponseJSON = { + id: publicKeyCredential.id, + authenticatorAttachment: publicKeyCredential.authenticatorAttachment, + type: publicKeyCredential.type, + rawId: this._base64url.encode(publicKeyCredential.rawId), + response: { + clientDataJSON: this._base64url.encode( + publicKeyCredential.response.clientDataJSON + ), + attestationObject: this._base64url.encode( + publicKeyCredential.response.attestationObject + ), + }, + }; + this._verifyRegistrationPasskey(credentials); + } catch (error: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.register_failed"), + text: error.message, + }); + } + } + + private async _verifyRegistrationPasskey( + credential: PublicKeyRegistartionCredentialResponseJSON + ) { + try { + this.hass + .callWS({ + type: "config/auth_provider/passkey/register_verify", + credential: credential, + }) + .then(() => { + fireEvent(this, "hass-refresh-passkeys"); + }); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.register_failed"), + text: err.message, + }); + } + } + + private _base64url = { + encode: function (buffer) { + const base64 = window.btoa( + String.fromCharCode(...new Uint8Array(buffer)) + ); + return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + }, + decode: function (base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const binStr = window.atob(base64); + const bin = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + bin[i] = binStr.charCodeAt(i); + } + return bin.buffer; + }, + }; + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ha-settings-row { + padding: 0; + --settings-row-prefix-display: contents; + --settings-row-content-display: contents; + } + ha-icon-button { + color: var(--primary-text-color); + } + ha-list-item[disabled], + ha-list-item[disabled] ha-svg-icon { + color: var(--disabled-text-color) !important; + } + ha-settings-row .current-session { + display: inline-flex; + align-items: center; + } + ha-settings-row .dot { + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--success-color); + border-radius: 50%; + margin-right: 6px; + } + ha-settings-row > ha-svg-icon { + margin-right: 12px; + margin-inline-start: initial; + margin-inline-end: 12px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-setup-passkey-card": HaSetupPasskeyCard; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 68eb675f0584..78689196221a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6868,6 +6868,25 @@ "empty_state": "You have no long-lived access tokens yet.", "qr_code_image": "QR code for token {name}", "generate_qr_code": "Generate QR Code" + }, + "passkeys": { + "header": "Passkeys", + "description": "Passkeys are webauthn credentials that validate your identity using touch, facial recognition, a device password, or a PIN. They can be used as a password replacement.", + "learn_auth_requests": "Learn how to make authenticated requests.", + "created_at": "Created {date}", + "last_used": "Last used {date}", + "confirm_delete_title": "Delete passkey?", + "confirm_delete_text": "Are you sure you want to delete the passkey ''{name}''?", + "delete_failed": "Failed to delete the passkey.", + "create": "Create Passkey", + "create_failed": "Failed to create the passkey.", + "name": "Name", + "prompt_name": "Give the passkey a name", + "prompt_copy_passkey": "Copy your passkey. It will not be shown again.", + "empty_state": "You have no passkeys yet.", + "not_used": "Has never been used", + "rename_failed": "Failed to rename the passkey.", + "register_failed": "Failed to register the passkey." } }, "todo": { @@ -6987,6 +7006,23 @@ "abort": { "not_allowed": "Your computer is not allowed." } + }, + "webauthn": { + "step": { + "init": { + "data": { + "username": "Username" + }, + "description": "Please enter your username." + } + }, + "error": { + "invalid_user": "Invalid username", + "invalid_auth": "Invalid username" + }, + "abort": { + "not_allowed": "Selected user is not allowed." + } } } } From 357347167198a7357e4bc67ce27b0ce30683e48d Mon Sep 17 00:00:00 2001 From: Vitalii Martyniak Date: Wed, 11 Sep 2024 18:59:13 +0200 Subject: [PATCH 2/7] Update error description for invalid_auth --- src/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/translations/en.json b/src/translations/en.json index 78689196221a..b5410a0b2f2b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7018,7 +7018,7 @@ }, "error": { "invalid_user": "Invalid username", - "invalid_auth": "Invalid username" + "invalid_auth": "Invalid authentication" }, "abort": { "not_allowed": "Selected user is not allowed." From 581074e24f39caf5e0264d17b67755150b72e047 Mon Sep 17 00:00:00 2001 From: Vitalii Martyniak Date: Wed, 11 Sep 2024 19:05:00 +0200 Subject: [PATCH 3/7] Add util functions for passkey management --- src/data/webauthn.ts | 96 ++++++++++++++++++++ src/panels/profile/ha-setup-passkey-card.ts | 98 ++------------------- 2 files changed, 105 insertions(+), 89 deletions(-) diff --git a/src/data/webauthn.ts b/src/data/webauthn.ts index 042b2c96ba72..df955c32c2bc 100644 --- a/src/data/webauthn.ts +++ b/src/data/webauthn.ts @@ -1,4 +1,5 @@ import type { HaFormSchema } from "../components/ha-form/types"; +import type { HomeAssistant } from "../types"; declare global { interface HASSDomEvents { @@ -193,3 +194,98 @@ export interface DataEntryFlowStepChallengeForm { preview?: string; translation_domain?: string; } + +export const base64url = { + encode: function (buffer) { + const base64 = window.btoa(String.fromCharCode(...new Uint8Array(buffer))); + return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + }, + decode: function (_base64url) { + const base64 = _base64url.replace(/-/g, "+").replace(/_/g, "/"); + const binStr = window.atob(base64); + const bin = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + bin[i] = binStr.charCodeAt(i); + } + return bin.buffer; + }, +}; + +const _generateRegistrationCredentialsJSON = async ( + registrationOptions: PublicKeyCredentialCreationOptions +) => { + const result = await navigator.credentials.create({ + publicKey: registrationOptions, + }); + + const publicKeyCredential = result as PublicKeyRegistartionCredentialResponse; + const credentials: PublicKeyRegistartionCredentialResponseJSON = { + id: publicKeyCredential.id, + authenticatorAttachment: publicKeyCredential.authenticatorAttachment, + type: publicKeyCredential.type, + rawId: base64url.encode(publicKeyCredential.rawId), + response: { + clientDataJSON: base64url.encode( + publicKeyCredential.response.clientDataJSON + ), + attestationObject: base64url.encode( + publicKeyCredential.response.attestationObject + ), + }, + }; + return credentials; +}; + +const _verifyRegistration = async ( + hass: HomeAssistant, + credentials: PublicKeyRegistartionCredentialResponseJSON +) => { + await hass.callWS({ + type: "config/auth_provider/passkey/register_verify", + credential: credentials, + }); +}; + +export const registerPasskey = async (hass: HomeAssistant) => { + const registrationOptions: PublicKeyCredentialCreationOptionsJSON = + await hass.callWS({ + type: "config/auth_provider/passkey/register", + }); + const options: PublicKeyCredentialCreationOptions = { + ...registrationOptions, + user: { + ...registrationOptions.user, + id: base64url.decode(registrationOptions.user.id), + }, + challenge: base64url.decode(registrationOptions.challenge), + excludeCredentials: registrationOptions.excludeCredentials.map((cred) => ({ + ...cred, + id: base64url.decode(cred.id), + })), + }; + + const credentials = await _generateRegistrationCredentialsJSON(options); + await _verifyRegistration(hass, credentials); +}; + +export const deletePasskey = async ( + hass: HomeAssistant, + credential_id: string +) => { + await hass.callWS({ + type: "config/auth_provider/passkey/delete", + credential_id, + }); +}; + +export const renamePasskey = async ( + hass: HomeAssistant, + credential_id: string, + name: string +) => { + await hass.callWS({ + type: "config/auth_provider/passkey/rename", + credential_id, + name, + }); +}; diff --git a/src/panels/profile/ha-setup-passkey-card.ts b/src/panels/profile/ha-setup-passkey-card.ts index 37cf1bc23cb0..39d1de550f01 100644 --- a/src/panels/profile/ha-setup-passkey-card.ts +++ b/src/panels/profile/ha-setup-passkey-card.ts @@ -10,10 +10,9 @@ import "../../components/ha-circular-progress"; import "../../components/ha-textfield"; import { Passkey, - PublicKeyCredentialCreationOptions, - PublicKeyCredentialCreationOptionsJSON, - PublicKeyRegistartionCredentialResponse, - PublicKeyRegistartionCredentialResponseJSON, + registerPasskey, + deletePasskey, + renamePasskey, } from "../../data/webauthn"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; @@ -90,8 +89,9 @@ class HaSetupPasskeyCard extends LitElement { > ${this.hass.localize("ui.common.rename")} - + @@ -134,11 +134,7 @@ class HaSetupPasskeyCard extends LitElement { return; } try { - await this.hass.callWS({ - type: "config/auth_provider/passkey/rename", - credential_id: passkey.credential_id, - name: newName, - }); + await renamePasskey(this.hass, passkey.credential_id, newName); fireEvent(this, "hass-refresh-passkeys"); } catch (err: any) { showAlertDialog(this, { @@ -165,10 +161,7 @@ class HaSetupPasskeyCard extends LitElement { return; } try { - await this.hass.callWS({ - type: "config/auth_provider/passkey/delete", - credential_id: passkey.credential_id, - }); + await deletePasskey(this.hass, passkey.credential_id); fireEvent(this, "hass-refresh-passkeys"); } catch (err: any) { showAlertDialog(this, { @@ -180,43 +173,8 @@ class HaSetupPasskeyCard extends LitElement { private async _registerPasskey() { try { - const registrationOptions: PublicKeyCredentialCreationOptionsJSON = - await this.hass.callWS({ - type: "config/auth_provider/passkey/register", - }); - const options: PublicKeyCredentialCreationOptions = { - ...registrationOptions, - user: { - ...registrationOptions.user, - id: this._base64url.decode(registrationOptions.user.id), - }, - challenge: this._base64url.decode(registrationOptions.challenge), - excludeCredentials: registrationOptions.excludeCredentials.map( - (cred) => ({ - ...cred, - id: this._base64url.decode(cred.id), - }) - ), - }; - - const result = await navigator.credentials.create({ publicKey: options }); - const publicKeyCredential = - result as PublicKeyRegistartionCredentialResponse; - const credentials: PublicKeyRegistartionCredentialResponseJSON = { - id: publicKeyCredential.id, - authenticatorAttachment: publicKeyCredential.authenticatorAttachment, - type: publicKeyCredential.type, - rawId: this._base64url.encode(publicKeyCredential.rawId), - response: { - clientDataJSON: this._base64url.encode( - publicKeyCredential.response.clientDataJSON - ), - attestationObject: this._base64url.encode( - publicKeyCredential.response.attestationObject - ), - }, - }; - this._verifyRegistrationPasskey(credentials); + await registerPasskey(this.hass); + fireEvent(this, "hass-refresh-passkeys"); } catch (error: any) { showAlertDialog(this, { title: this.hass.localize("ui.panel.profile.passkeys.register_failed"), @@ -225,44 +183,6 @@ class HaSetupPasskeyCard extends LitElement { } } - private async _verifyRegistrationPasskey( - credential: PublicKeyRegistartionCredentialResponseJSON - ) { - try { - this.hass - .callWS({ - type: "config/auth_provider/passkey/register_verify", - credential: credential, - }) - .then(() => { - fireEvent(this, "hass-refresh-passkeys"); - }); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize("ui.panel.profile.passkeys.register_failed"), - text: err.message, - }); - } - } - - private _base64url = { - encode: function (buffer) { - const base64 = window.btoa( - String.fromCharCode(...new Uint8Array(buffer)) - ); - return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); - }, - decode: function (base64url) { - const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); - const binStr = window.atob(base64); - const bin = new Uint8Array(binStr.length); - for (let i = 0; i < binStr.length; i++) { - bin[i] = binStr.charCodeAt(i); - } - return bin.buffer; - }, - }; - static get styles(): CSSResultGroup { return [ haStyle, From 2ebfa6299fd4cb81226571849726440a3b25f527 Mon Sep 17 00:00:00 2001 From: Vitalii Martyniak Date: Wed, 11 Sep 2024 19:09:11 +0200 Subject: [PATCH 4/7] Hide passkey configuration form when webauthn provider not configured --- .../profile/ha-profile-section-security.ts | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/panels/profile/ha-profile-section-security.ts b/src/panels/profile/ha-profile-section-security.ts index cf5f9eb91b0f..877aef6062bf 100644 --- a/src/panels/profile/ha-profile-section-security.ts +++ b/src/panels/profile/ha-profile-section-security.ts @@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators"; import "../../layouts/hass-tabs-subpage"; import { profileSections } from "./ha-panel-profile"; import type { RefreshToken } from "../../data/refresh_token"; +import { AuthProvider, fetchAuthProviders } from "../../data/auth"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant, Route } from "../../types"; import "./ha-change-password-card"; @@ -21,6 +22,8 @@ class HaProfileSectionSecurity extends LitElement { @state() private _refreshTokens?: RefreshToken[]; + @state() private _authProviders?: AuthProvider[]; + @state() private _passkeys?: Passkey[]; @property({ attribute: false }) public route!: Route; @@ -28,14 +31,15 @@ class HaProfileSectionSecurity extends LitElement { public connectedCallback() { super.connectedCallback(); this._refreshRefreshTokens(); + this._fetchAuthProviders(); } public firstUpdated() { if (!this._refreshTokens) { this._refreshRefreshTokens(); } - if (!this._passkeys) { - this._refreshPasskeys(); + if (!this._authProviders) { + this._fetchAuthProviders(); } } @@ -61,12 +65,17 @@ class HaProfileSectionSecurity extends LitElement { > ` : ""} - - + ${this._authProviders?.some( + (provider) => provider.type === "webauthn" + ) + ? html` + + ` + : ""} provider.type === "webauthn") + ) { + this._refreshPasskeys(); + } + } + static get styles(): CSSResultGroup { return [ haStyle, From 8f2ea1ab62f5a8053a9f939384bcd4653c7a965b Mon Sep 17 00:00:00 2001 From: Vitalii Martyniak Date: Wed, 11 Sep 2024 20:07:07 +0200 Subject: [PATCH 5/7] Add util functions for passkey authentication --- src/auth/ha-auth-flow.ts | 73 +++------------------------ src/data/webauthn.ts | 104 +++++++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 102 deletions(-) diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index 34691721e2a6..eb89c9156b87 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -17,12 +17,7 @@ import { redirectWithAuthCode, submitLoginFlow, } from "../data/auth"; -import type { - PublicKeyCredentialRequestOptions, - PublicKeyCredentialRequestOptionsJSON, - AuthenticationCredentialJSON, - AuthenticationCredential, -} from "../data/webauthn"; +import { generateAuthenticationCredentialsJSON } from "../data/webauthn"; import type { DataEntryFlowStep, DataEntryFlowStepForm, @@ -274,24 +269,6 @@ export class HaAuthFlow extends LitElement { } } - private _base64url = { - encode: function (buffer) { - const base64 = window.btoa( - String.fromCharCode(...new Uint8Array(buffer)) - ); - return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); - }, - decode: function (base64url) { - const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); - const binStr = window.atob(base64); - const bin = new Uint8Array(binStr.length); - for (let i = 0; i < binStr.length; i++) { - bin[i] = binStr.charCodeAt(i); - } - return bin.buffer; - }, - }; - private _storeTokenChanged(e: CustomEvent) { this._storeToken = (e.currentTarget as HTMLInputElement).checked; } @@ -431,60 +408,26 @@ export class HaAuthFlow extends LitElement { } } - private async _getWebauthnCredentials( - publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptionsJSON - ) { - const publicKeyOptions: PublicKeyCredentialRequestOptions = { - ...publicKeyCredentialRequestOptions, - challenge: this._base64url.decode( - publicKeyCredentialRequestOptions.challenge - ), - allowCredentials: publicKeyCredentialRequestOptions.allowCredentials.map( - (cred) => ({ - ...cred, - id: this._base64url.decode(cred.id), - }) - ), - }; + private async _getWebauthnCredentials(publicKeyCredentialRequestOptions) { try { - const result = await navigator.credentials.get({ - publicKey: publicKeyOptions, - }); - const authenticationCredential = result as AuthenticationCredential; - const authenticationCredentialJSON: AuthenticationCredentialJSON = { - id: authenticationCredential.id, - authenticatorAttachment: - authenticationCredential.authenticatorAttachment, - rawId: this._base64url.encode(authenticationCredential.rawId), - response: { - userHandle: this._base64url.encode( - authenticationCredential.response.userHandle - ), - clientDataJSON: this._base64url.encode( - authenticationCredential.response.clientDataJSON - ), - authenticatorData: this._base64url.encode( - authenticationCredential.response.authenticatorData - ), - signature: this._base64url.encode( - authenticationCredential.response.signature - ), - }, - type: authenticationCredential.type, - }; + const authenticationCredentialJSON = + await generateAuthenticationCredentialsJSON( + publicKeyCredentialRequestOptions + ); this._stepData = { authentication_credential: authenticationCredentialJSON, client_id: this.clientId, }; this._handleSubmit(new Event("submit")); } catch (err: any) { - // eslint-disable-next-line no-console if (err instanceof DOMException) { this._errorMessage = "WebAuthn operation was aborted."; } else { this._errorMessage = "An unexpected error occurred during WebAuthn authentication."; } + // eslint-disable-next-line no-console + console.error("Error getting WebAuthn credentials", err); this._state = "error"; } finally { this._submitting = false; diff --git a/src/data/webauthn.ts b/src/data/webauthn.ts index df955c32c2bc..9e712f9cff7d 100644 --- a/src/data/webauthn.ts +++ b/src/data/webauthn.ts @@ -15,7 +15,7 @@ export interface Passkey { last_used_at?: string; } -export interface PublicKeyCredentialCreationOptionsJSON { +interface PublicKeyCredentialCreationOptionsJSON { rp: PublicKeyCredentialRpEntity; user: PublicKeyCredentialUserEntityJSON; challenge: string; @@ -27,19 +27,19 @@ export interface PublicKeyCredentialCreationOptionsJSON { extensions?: AuthenticationExtensionsClientInputs; } -export interface PublicKeyCredentialUserEntityJSON { +interface PublicKeyCredentialUserEntityJSON { id: string; name: string; displayName: string; } -export interface PublicKeyCredentialDescriptorJSON { +interface PublicKeyCredentialDescriptorJSON { type: PublicKeyCredentialType; id: string; transports?: AuthenticatorTransport[]; } -export interface PublicKeyCredentialCreationOptions { +interface PublicKeyCredentialCreationOptions { rp: PublicKeyCredentialRpEntity; user: PublicKeyCredentialUserEntity; challenge: BufferSource; @@ -51,7 +51,7 @@ export interface PublicKeyCredentialCreationOptions { extensions?: AuthenticationExtensionsClientInputs; } -export interface PublicKeyCredentialRequestOptionsJSON { +interface PublicKeyCredentialRequestOptionsJSON { id: string; challenge: string; timeout?: number; @@ -61,7 +61,7 @@ export interface PublicKeyCredentialRequestOptionsJSON { extensions?: AuthenticationExtensionsClientInputs; } -export interface PublicKeyCredentialRequestOptions { +interface PublicKeyCredentialRequestOptions { id: string; challenge: BufferSource; timeout?: number; @@ -71,74 +71,63 @@ export interface PublicKeyCredentialRequestOptions { extensions?: AuthenticationExtensionsClientInputs; } -export interface PublicKeyCredentialRpEntity { +interface PublicKeyCredentialRpEntity { id: string; name: string; } -export interface PublicKeyCredentialUserEntity { +interface PublicKeyCredentialUserEntity { id: BufferSource; name: string; displayName: string; } -export interface PublicKeyCredentialParameters { +interface PublicKeyCredentialParameters { type: PublicKeyCredentialType; alg: COSEAlgorithmIdentifier; } -export type PublicKeyCredentialType = "public-key"; +type PublicKeyCredentialType = "public-key"; -export type COSEAlgorithmIdentifier = -7 | -257 | -65535 | -257 | -65535; +type COSEAlgorithmIdentifier = -7 | -257 | -65535 | -257 | -65535; -export interface PublicKeyCredentialDescriptor { +interface PublicKeyCredentialDescriptor { type: PublicKeyCredentialType; id: BufferSource; transports?: AuthenticatorTransport[]; } -export type AuthenticatorTransport = "usb" | "nfc" | "ble" | "internal"; +type AuthenticatorTransport = "usb" | "nfc" | "ble" | "internal"; -export type AuthenticatorAttachment = "platform" | "cross-platform"; +type AuthenticatorAttachment = "platform" | "cross-platform"; -export type UserVerificationRequirement = - | "required" - | "preferred" - | "discouraged"; +type UserVerificationRequirement = "required" | "preferred" | "discouraged"; -export interface AuthenticatorSelectionCriteria { +interface AuthenticatorSelectionCriteria { authenticatorAttachment?: AuthenticatorAttachment; requireResidentKey?: boolean; userVerification?: UserVerificationRequirement; } -export type AttestationConveyancePreference = "none" | "indirect" | "direct"; +type AttestationConveyancePreference = "none" | "indirect" | "direct"; -export interface AuthenticationExtensionsClientInputs { +interface AuthenticationExtensionsClientInputs { [key: string]: any; } -export interface PublicKeyCredentialWithClientExtensionOutputs { - clientExtensionResults: AuthenticationExtensionsClientOutputs; -} - -export interface AuthenticationExtensionsClientOutputs { - [key: string]: any; -} - -export interface PublicKeyCredentialAttestationResponse { +interface PublicKeyCredentialAttestationResponse { clientDataJSON: BufferSource; attestationObject: BufferSource; } -export interface PublicKeyCredentialAssertionResponse { +interface PublicKeyCredentialAssertionResponse { clientDataJSON: BufferSource; authenticatorData: BufferSource; signature: BufferSource; userHandle: BufferSource; } -export interface PublicKeyRegistartionCredentialResponseJSON { +interface PublicKeyRegistartionCredentialResponseJSON { authenticatorAttachment: string; id: string; rawId: string; @@ -146,12 +135,12 @@ export interface PublicKeyRegistartionCredentialResponseJSON { type: string; } -export interface PublicKeyCredentialAttestationResponseJSON { +interface PublicKeyCredentialAttestationResponseJSON { clientDataJSON: string; attestationObject: string; } -export interface PublicKeyRegistartionCredentialResponse { +interface PublicKeyRegistartionCredentialResponse { authenticatorAttachment: string; id: string; rawId: BufferSource; @@ -159,7 +148,7 @@ export interface PublicKeyRegistartionCredentialResponse { type: string; } -export interface AuthenticationCredentialJSON { +interface AuthenticationCredentialJSON { authenticatorAttachment: string; id: string; rawId: string; @@ -167,14 +156,14 @@ export interface AuthenticationCredentialJSON { type: string; } -export interface PublicKeyCredentialAssertionResponseJSON { +interface PublicKeyCredentialAssertionResponseJSON { clientDataJSON: string; authenticatorData: string; signature: string; userHandle: string; } -export interface AuthenticationCredential { +interface AuthenticationCredential { authenticatorAttachment: string; id: string; rawId: BufferSource; @@ -236,6 +225,26 @@ const _generateRegistrationCredentialsJSON = async ( return credentials; }; +const _generateAuthenticationCredentialsJSON = async ( + authCredentials: AuthenticationCredential +) => { + const authenticationCredentialJSON: AuthenticationCredentialJSON = { + id: authCredentials.id, + authenticatorAttachment: authCredentials.authenticatorAttachment, + rawId: base64url.encode(authCredentials.rawId), + response: { + userHandle: base64url.encode(authCredentials.response.userHandle), + clientDataJSON: base64url.encode(authCredentials.response.clientDataJSON), + authenticatorData: base64url.encode( + authCredentials.response.authenticatorData + ), + signature: base64url.encode(authCredentials.response.signature), + }, + type: authCredentials.type, + }; + return authenticationCredentialJSON; +}; + const _verifyRegistration = async ( hass: HomeAssistant, credentials: PublicKeyRegistartionCredentialResponseJSON @@ -289,3 +298,24 @@ export const renamePasskey = async ( name, }); }; + +export const generateAuthenticationCredentialsJSON = async ( + publicKeyOptions: PublicKeyCredentialRequestOptionsJSON +) => { + const _publicKeyOptions: PublicKeyCredentialRequestOptions = { + ...publicKeyOptions, + challenge: base64url.decode(publicKeyOptions.challenge), + allowCredentials: publicKeyOptions.allowCredentials.map((cred) => ({ + ...cred, + id: base64url.decode(cred.id), + })), + }; + + const result = await navigator.credentials.get({ + publicKey: _publicKeyOptions, + }); + const authenticationCredential = result as AuthenticationCredential; + const authenticationCredentialJSON = + await _generateAuthenticationCredentialsJSON(authenticationCredential); + return authenticationCredentialJSON; +}; From bde4049627b307704f4f99f87364dcd88bcd5f61 Mon Sep 17 00:00:00 2001 From: Vitalii Martyniak Date: Fri, 13 Sep 2024 08:13:24 +0200 Subject: [PATCH 6/7] Fix typo in interface names --- src/data/webauthn.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/data/webauthn.ts b/src/data/webauthn.ts index 9e712f9cff7d..f891b4b87772 100644 --- a/src/data/webauthn.ts +++ b/src/data/webauthn.ts @@ -127,7 +127,7 @@ interface PublicKeyCredentialAssertionResponse { userHandle: BufferSource; } -interface PublicKeyRegistartionCredentialResponseJSON { +interface PublicKeyRegistrationCredentialResponseJSON { authenticatorAttachment: string; id: string; rawId: string; @@ -140,7 +140,7 @@ interface PublicKeyCredentialAttestationResponseJSON { attestationObject: string; } -interface PublicKeyRegistartionCredentialResponse { +interface PublicKeyRegistrationCredentialResponse { authenticatorAttachment: string; id: string; rawId: BufferSource; @@ -207,8 +207,8 @@ const _generateRegistrationCredentialsJSON = async ( publicKey: registrationOptions, }); - const publicKeyCredential = result as PublicKeyRegistartionCredentialResponse; - const credentials: PublicKeyRegistartionCredentialResponseJSON = { + const publicKeyCredential = result as PublicKeyRegistrationCredentialResponse; + const credentials: PublicKeyRegistrationCredentialResponseJSON = { id: publicKeyCredential.id, authenticatorAttachment: publicKeyCredential.authenticatorAttachment, type: publicKeyCredential.type, @@ -247,7 +247,7 @@ const _generateAuthenticationCredentialsJSON = async ( const _verifyRegistration = async ( hass: HomeAssistant, - credentials: PublicKeyRegistartionCredentialResponseJSON + credentials: PublicKeyRegistrationCredentialResponseJSON ) => { await hass.callWS({ type: "config/auth_provider/passkey/register_verify", From 0243bd8dcd062c28035ed15596ae1c65f2dfa7fb Mon Sep 17 00:00:00 2001 From: Vitalii Martyniak Date: Wed, 13 Nov 2024 12:56:38 +0100 Subject: [PATCH 7/7] Fix lint issues --- src/panels/profile/ha-profile-section-security.ts | 5 +++-- src/panels/profile/ha-setup-passkey-card.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/panels/profile/ha-profile-section-security.ts b/src/panels/profile/ha-profile-section-security.ts index 877aef6062bf..003b4c51178f 100644 --- a/src/panels/profile/ha-profile-section-security.ts +++ b/src/panels/profile/ha-profile-section-security.ts @@ -4,7 +4,8 @@ import { customElement, property, state } from "lit/decorators"; import "../../layouts/hass-tabs-subpage"; import { profileSections } from "./ha-panel-profile"; import type { RefreshToken } from "../../data/refresh_token"; -import { AuthProvider, fetchAuthProviders } from "../../data/auth"; +import type { AuthProvider } from "../../data/auth"; +import { fetchAuthProviders } from "../../data/auth"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant, Route } from "../../types"; import "./ha-change-password-card"; @@ -12,7 +13,7 @@ import "./ha-long-lived-access-tokens-card"; import "./ha-mfa-modules-card"; import "./ha-refresh-tokens-card"; import "./ha-setup-passkey-card"; -import { Passkey } from "../../data/webauthn"; +import type { Passkey } from "../../data/webauthn"; @customElement("ha-profile-section-security") class HaProfileSectionSecurity extends LitElement { diff --git a/src/panels/profile/ha-setup-passkey-card.ts b/src/panels/profile/ha-setup-passkey-card.ts index 39d1de550f01..28a6dcc9bfa3 100644 --- a/src/panels/profile/ha-setup-passkey-card.ts +++ b/src/panels/profile/ha-setup-passkey-card.ts @@ -1,15 +1,19 @@ import "@material/mwc-button"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import type { CSSResultGroup, TemplateResult } from "lit"; +import { css, html, LitElement } from "lit"; import { mdiKey, mdiDotsVertical, mdiDelete, mdiRename } from "@mdi/js"; import { customElement, property } from "lit/decorators"; -import { ActionDetail } from "@material/mwc-list"; +import type { ActionDetail } from "@material/mwc-list"; import { relativeTime } from "../../common/datetime/relative_time"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-card"; import "../../components/ha-circular-progress"; import "../../components/ha-textfield"; +import "../../components/ha-settings-row"; +import "../../components/ha-list-item"; +import "../../components/ha-button-menu"; +import type { Passkey } from "../../data/webauthn"; import { - Passkey, registerPasskey, deletePasskey, renamePasskey,