diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index 668beb4685a6..eb89c9156b87 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -17,6 +17,7 @@ import { redirectWithAuthCode, submitLoginFlow, } from "../data/auth"; +import { generateAuthenticationCredentialsJSON } from "../data/webauthn"; import type { DataEntryFlowStep, DataEntryFlowStepForm, @@ -216,7 +217,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,6 +263,7 @@ export class HaAuthFlow extends LitElement { ` : ""} `; + } default: return nothing; } @@ -373,6 +375,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 +407,32 @@ export class HaAuthFlow extends LitElement { this._submitting = false; } } + + private async _getWebauthnCredentials(publicKeyCredentialRequestOptions) { + try { + const authenticationCredentialJSON = + await generateAuthenticationCredentialsJSON( + publicKeyCredentialRequestOptions + ); + this._stepData = { + authentication_credential: authenticationCredentialJSON, + client_id: this.clientId, + }; + this._handleSubmit(new Event("submit")); + } catch (err: any) { + 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; + } + } } 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..f891b4b87772 --- /dev/null +++ b/src/data/webauthn.ts @@ -0,0 +1,321 @@ +import type { HaFormSchema } from "../components/ha-form/types"; +import type { HomeAssistant } from "../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; +} + +interface PublicKeyCredentialCreationOptionsJSON { + rp: PublicKeyCredentialRpEntity; + user: PublicKeyCredentialUserEntityJSON; + challenge: string; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout?: number; + excludeCredentials: PublicKeyCredentialDescriptorJSON[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + attestation?: AttestationConveyancePreference; + extensions?: AuthenticationExtensionsClientInputs; +} + +interface PublicKeyCredentialUserEntityJSON { + id: string; + name: string; + displayName: string; +} + +interface PublicKeyCredentialDescriptorJSON { + type: PublicKeyCredentialType; + id: string; + transports?: AuthenticatorTransport[]; +} + +interface PublicKeyCredentialCreationOptions { + rp: PublicKeyCredentialRpEntity; + user: PublicKeyCredentialUserEntity; + challenge: BufferSource; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout?: number; + excludeCredentials?: PublicKeyCredentialDescriptor[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + attestation?: AttestationConveyancePreference; + extensions?: AuthenticationExtensionsClientInputs; +} + +interface PublicKeyCredentialRequestOptionsJSON { + id: string; + challenge: string; + timeout?: number; + rpId?: string; + allowCredentials: PublicKeyCredentialDescriptorJSON[]; + userVerification?: UserVerificationRequirement; + extensions?: AuthenticationExtensionsClientInputs; +} + +interface PublicKeyCredentialRequestOptions { + id: string; + challenge: BufferSource; + timeout?: number; + rpId?: string; + allowCredentials?: PublicKeyCredentialDescriptor[]; + userVerification?: UserVerificationRequirement; + extensions?: AuthenticationExtensionsClientInputs; +} + +interface PublicKeyCredentialRpEntity { + id: string; + name: string; +} + +interface PublicKeyCredentialUserEntity { + id: BufferSource; + name: string; + displayName: string; +} + +interface PublicKeyCredentialParameters { + type: PublicKeyCredentialType; + alg: COSEAlgorithmIdentifier; +} + +type PublicKeyCredentialType = "public-key"; + +type COSEAlgorithmIdentifier = -7 | -257 | -65535 | -257 | -65535; + +interface PublicKeyCredentialDescriptor { + type: PublicKeyCredentialType; + id: BufferSource; + transports?: AuthenticatorTransport[]; +} + +type AuthenticatorTransport = "usb" | "nfc" | "ble" | "internal"; + +type AuthenticatorAttachment = "platform" | "cross-platform"; + +type UserVerificationRequirement = "required" | "preferred" | "discouraged"; + +interface AuthenticatorSelectionCriteria { + authenticatorAttachment?: AuthenticatorAttachment; + requireResidentKey?: boolean; + userVerification?: UserVerificationRequirement; +} + +type AttestationConveyancePreference = "none" | "indirect" | "direct"; + +interface AuthenticationExtensionsClientInputs { + [key: string]: any; +} + +interface PublicKeyCredentialAttestationResponse { + clientDataJSON: BufferSource; + attestationObject: BufferSource; +} + +interface PublicKeyCredentialAssertionResponse { + clientDataJSON: BufferSource; + authenticatorData: BufferSource; + signature: BufferSource; + userHandle: BufferSource; +} + +interface PublicKeyRegistrationCredentialResponseJSON { + authenticatorAttachment: string; + id: string; + rawId: string; + response: PublicKeyCredentialAttestationResponseJSON; + type: string; +} + +interface PublicKeyCredentialAttestationResponseJSON { + clientDataJSON: string; + attestationObject: string; +} + +interface PublicKeyRegistrationCredentialResponse { + authenticatorAttachment: string; + id: string; + rawId: BufferSource; + response: PublicKeyCredentialAttestationResponse; + type: string; +} + +interface AuthenticationCredentialJSON { + authenticatorAttachment: string; + id: string; + rawId: string; + response: PublicKeyCredentialAssertionResponseJSON; + type: string; +} + +interface PublicKeyCredentialAssertionResponseJSON { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string; +} + +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; +} + +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 PublicKeyRegistrationCredentialResponse; + const credentials: PublicKeyRegistrationCredentialResponseJSON = { + 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 _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: PublicKeyRegistrationCredentialResponseJSON +) => { + 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, + }); +}; + +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; +}; diff --git a/src/panels/profile/ha-profile-section-security.ts b/src/panels/profile/ha-profile-section-security.ts index 83b2043138ea..003b4c51178f 100644 --- a/src/panels/profile/ha-profile-section-security.ts +++ b/src/panels/profile/ha-profile-section-security.ts @@ -4,12 +4,16 @@ 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 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"; import "./ha-long-lived-access-tokens-card"; import "./ha-mfa-modules-card"; import "./ha-refresh-tokens-card"; +import "./ha-setup-passkey-card"; +import type { Passkey } from "../../data/webauthn"; @customElement("ha-profile-section-security") class HaProfileSectionSecurity extends LitElement { @@ -19,17 +23,25 @@ class HaProfileSectionSecurity extends LitElement { @state() private _refreshTokens?: RefreshToken[]; + @state() private _authProviders?: AuthProvider[]; + + @state() private _passkeys?: Passkey[]; + @property({ attribute: false }) public route!: Route; public connectedCallback() { super.connectedCallback(); this._refreshRefreshTokens(); + this._fetchAuthProviders(); } public firstUpdated() { if (!this._refreshTokens) { this._refreshRefreshTokens(); } + if (!this._authProviders) { + this._fetchAuthProviders(); + } } protected render(): TemplateResult { @@ -54,6 +66,18 @@ class HaProfileSectionSecurity extends LitElement { > ` : ""} + ${this._authProviders?.some( + (provider) => provider.type === "webauthn" + ) + ? html` + + ` + : ""} + ({ + type: "config/auth_provider/passkey/list", + }); + } + + private async _fetchAuthProviders() { + if (!this.hass) { + return; + } + + const response = await ((window as any).providersPromise || + fetchAuthProviders()); + const authProviders = await response.json(); + this._authProviders = authProviders.providers; + + if ( + !this._passkeys && + this._authProviders?.some((provider) => provider.type === "webauthn") + ) { + this._refreshPasskeys(); + } + } + 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..28a6dcc9bfa3 --- /dev/null +++ b/src/panels/profile/ha-setup-passkey-card.ts @@ -0,0 +1,232 @@ +import "@material/mwc-button"; +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 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 { + registerPasskey, + deletePasskey, + renamePasskey, +} 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 renamePasskey(this.hass, passkey.credential_id, 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 deletePasskey(this.hass, 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 { + await registerPasskey(this.hass); + fireEvent(this, "hass-refresh-passkeys"); + } catch (error: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.register_failed"), + text: error.message, + }); + } + } + + 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..b5410a0b2f2b 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 authentication" + }, + "abort": { + "not_allowed": "Selected user is not allowed." + } } } }