mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 19:23:19 +00:00
Beta: Windows Native passkey provider
Co-Authored-By: Isaiah Inuwa <iinuwa@bitwarden.com> Co-Authored-By: Colton Hurst <colton@coltonhurst.com>
This commit is contained in:
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -8,7 +8,9 @@
|
||||
apps/desktop/desktop_native @bitwarden/team-platform-dev
|
||||
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev
|
||||
|
||||
## No ownership for Cargo.lock and Cargo.toml to allow dependency updates
|
||||
apps/desktop/desktop_native/Cargo.lock
|
||||
apps/desktop/desktop_native/Cargo.toml
|
||||
|
||||
30
.github/workflows/build-desktop.yml
vendored
30
.github/workflows/build-desktop.yml
vendored
@@ -908,7 +908,7 @@ jobs:
|
||||
|
||||
macos-build:
|
||||
name: MacOS Build
|
||||
runs-on: macos-13
|
||||
runs-on: macos-15
|
||||
needs:
|
||||
- setup
|
||||
permissions:
|
||||
@@ -935,8 +935,13 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Set up Node-gyp
|
||||
run: python3 -m pip install setuptools
|
||||
run: python -m pip install setuptools
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -945,6 +950,7 @@ jobs:
|
||||
rustup show
|
||||
echo "GitHub ref: $GITHUB_REF"
|
||||
echo "GitHub event: $GITHUB_EVENT"
|
||||
xcodebuild -showsdks
|
||||
|
||||
- name: Cache Build
|
||||
id: build-cache
|
||||
@@ -1132,7 +1138,7 @@ jobs:
|
||||
|
||||
macos-package-github:
|
||||
name: MacOS Package GitHub Release Assets
|
||||
runs-on: macos-13
|
||||
runs-on: macos-15
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
needs:
|
||||
- browser-build
|
||||
@@ -1162,8 +1168,13 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Set up Node-gyp
|
||||
run: python3 -m pip install setuptools
|
||||
run: python -m pip install setuptools
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -1172,6 +1183,7 @@ jobs:
|
||||
rustup show
|
||||
echo "GitHub ref: $GITHUB_REF"
|
||||
echo "GitHub event: $GITHUB_EVENT"
|
||||
xcodebuild -showsdks
|
||||
|
||||
- name: Get Build Cache
|
||||
id: build-cache
|
||||
@@ -1393,7 +1405,7 @@ jobs:
|
||||
|
||||
macos-package-mas:
|
||||
name: MacOS Package Prod Release Asset
|
||||
runs-on: macos-13
|
||||
runs-on: macos-15
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
needs:
|
||||
- browser-build
|
||||
@@ -1423,8 +1435,13 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Set up Node-gyp
|
||||
run: python3 -m pip install setuptools
|
||||
run: python -m pip install setuptools
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -1433,6 +1450,7 @@ jobs:
|
||||
rustup show
|
||||
echo "GitHub ref: $GITHUB_REF"
|
||||
echo "GitHub event: $GITHUB_EVENT"
|
||||
xcodebuild -showsdks
|
||||
|
||||
- name: Get Build Cache
|
||||
id: build-cache
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -198,7 +199,7 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
this.displayedCiphers = this.ciphers.filter(
|
||||
(cipher) =>
|
||||
cipher.login.matchesUri(this.url, equivalentDomains) &&
|
||||
this.cipherHasNoOtherPasskeys(cipher, message.userHandle),
|
||||
Fido2Utils.cipherHasNoOtherPasskeys(cipher, message.userHandle),
|
||||
);
|
||||
|
||||
this.passkeyAction = PasskeyActions.Register;
|
||||
@@ -472,16 +473,4 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
...msg,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
|
||||
* @param userHandle
|
||||
*/
|
||||
private cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
|
||||
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
DisplayName="Bitwarden Password Manager">
|
||||
</uap3:AppExtension>
|
||||
</uap3:Extension>
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
<com:ExeServer Executable="Bitwarden.exe" DisplayName="Bitwarden Passkey Manager">
|
||||
<com:Class Id="0f7dc5d9-69ce-4652-8572-6877fd695062" DisplayName="Bitwarden Passkey Manager"/>
|
||||
</com:ExeServer>
|
||||
</com:ComServer>
|
||||
</com:Extension>
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
20
apps/desktop/.vscode/launch.json
vendored
20
apps/desktop/.vscode/launch.json
vendored
@@ -6,11 +6,25 @@
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceRoot}/build",
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
|
||||
"runtimeExecutable": "${workspaceRoot}/../../node_modules/.bin/electron",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
|
||||
"runtimeExecutable": "${workspaceRoot}/../../node_modules/.bin/electron.cmd"
|
||||
},
|
||||
"args": ["."]
|
||||
"args": [".", "--remote-debugging-port=9223"]
|
||||
},
|
||||
{
|
||||
"name": "Debug Renderer Process",
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"port": 9223,
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"timeout": 30000
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug Electron: All",
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
27
apps/desktop/build.ps1
Normal file
27
apps/desktop/build.ps1
Normal file
@@ -0,0 +1,27 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
|
||||
$env:ELECTRON_BUILDER_SIGN_CERT = "C:\temp\code-signing.pfx"
|
||||
$env:ELECTRON_BUILDER_SIGN_CERT_PW = "1234"
|
||||
$bwFolder = "$env:LOCALAPPDATA\Packages\bitwardendesktop_h4e712dmw3xyy"
|
||||
|
||||
$package = (Get-AppxPackage -name bitwardendesktop)
|
||||
$appx = ".\dist\Bitwarden-2025.10.2-arm64.appx"
|
||||
$backupDataFile = "C:\temp\bw-data.json"
|
||||
$comLogFile = "C:\temp\bitwarden_com_debug.log"
|
||||
|
||||
# Build Appx
|
||||
npm run build-native && npm run build:dev && npm run pack:win:arm64
|
||||
|
||||
# Backup tokens
|
||||
# Copy-Item -Path "$bwFolder\LocalCache\Roaming\Bitwarden\data.json" -Destination $backupDataFile
|
||||
|
||||
# Reinstall Appx
|
||||
Remove-AppxPackage $package && Add-AppxPackage $appx
|
||||
|
||||
# Delete log files
|
||||
Remove-Item -Path $comLogFile
|
||||
|
||||
# Restore tokens
|
||||
# New-Item -Type Directory -Force -Path "$bwFolder\LocalCache\Roaming\Bitwarden\"
|
||||
# Copy-Item -Path $backupDataFile -Destination "$bwFolder\LocalCache\Roaming\Bitwarden\data.json"
|
||||
BIN
apps/desktop/com.bitwarden.pfx
Normal file
BIN
apps/desktop/com.bitwarden.pfx
Normal file
Binary file not shown.
127
apps/desktop/custom-appx-manifest.xml
Normal file
127
apps/desktop/custom-appx-manifest.xml
Normal file
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--suppress XmlUnusedNamespaceDeclaration -->
|
||||
<!-- <Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"> -->
|
||||
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
|
||||
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
|
||||
IgnorableNamespaces="uap rescap com uap10 build"
|
||||
xmlns:build="http://schemas.microsoft.com/developer/appx/2015/build">
|
||||
<!-- use single quotes to avoid double quotes escaping in the publisher value -->
|
||||
<Identity Name="${applicationId}"
|
||||
ProcessorArchitecture="${arch}"
|
||||
Publisher='${publisher}'
|
||||
Version="${version}" />
|
||||
<Properties>
|
||||
<DisplayName>${displayName}</DisplayName>
|
||||
<PublisherDisplayName>${publisherDisplayName}</PublisherDisplayName>
|
||||
<Description>A secure and free password manager for all of your devices.</Description>
|
||||
<Logo>assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
<Resources>
|
||||
<Resource Language="en-US" />
|
||||
<Resource Language="af" />
|
||||
<Resource Language="ar" />
|
||||
<Resource Language="az-latn" />
|
||||
<Resource Language="be" />
|
||||
<Resource Language="bg" />
|
||||
<Resource Language="bn" />
|
||||
<Resource Language="bs" />
|
||||
<Resource Language="ca" />
|
||||
<Resource Language="cs" />
|
||||
<Resource Language="cy" />
|
||||
<Resource Language="da" />
|
||||
<Resource Language="de" />
|
||||
<Resource Language="el" />
|
||||
<Resource Language="en-gb" />
|
||||
<Resource Language="en-in" />
|
||||
<Resource Language="es" />
|
||||
<Resource Language="et" />
|
||||
<Resource Language="eu" />
|
||||
<Resource Language="fa" />
|
||||
<Resource Language="fi" />
|
||||
<Resource Language="fil" />
|
||||
<Resource Language="fr" />
|
||||
<Resource Language="gl" />
|
||||
<Resource Language="he" />
|
||||
<Resource Language="hi" />
|
||||
<Resource Language="hr" />
|
||||
<Resource Language="hu" />
|
||||
<Resource Language="id" />
|
||||
<Resource Language="it" />
|
||||
<Resource Language="ja" />
|
||||
<Resource Language="ka" />
|
||||
<Resource Language="km" />
|
||||
<Resource Language="kn" />
|
||||
<Resource Language="ko" />
|
||||
<Resource Language="lt" />
|
||||
<Resource Language="lv" />
|
||||
<Resource Language="ml" />
|
||||
<Resource Language="mr" />
|
||||
<Resource Language="nb" />
|
||||
<Resource Language="ne" />
|
||||
<Resource Language="nl" />
|
||||
<Resource Language="nn" />
|
||||
<Resource Language="or" />
|
||||
<Resource Language="pl" />
|
||||
<Resource Language="pt-br" />
|
||||
<Resource Language="pt-pt" />
|
||||
<Resource Language="ro" />
|
||||
<Resource Language="ru" />
|
||||
<Resource Language="si" />
|
||||
<Resource Language="sk" />
|
||||
<Resource Language="sl" />
|
||||
<Resource Language="sr-cyrl" />
|
||||
<Resource Language="sv" />
|
||||
<Resource Language="te" />
|
||||
<Resource Language="th" />
|
||||
<Resource Language="tr" />
|
||||
<Resource Language="uk" />
|
||||
<Resource Language="vi" />
|
||||
<Resource Language="zh-cn" />
|
||||
<Resource Language="zh-tw" />
|
||||
</Resources>
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.22635.4515"
|
||||
MaxVersionTested="10.0.26120.4250" />
|
||||
</Dependencies>
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
<Applications>
|
||||
<Application Id="bitwardendesktop" Executable="${executable}"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements
|
||||
BackgroundColor="#175DDC"
|
||||
DisplayName="${displayName}"
|
||||
Square150x150Logo="assets\Square150x150Logo.png"
|
||||
Square44x44Logo="assets\Square44x44Logo.png"
|
||||
Description="A secure and free password manager for all of your devices.">
|
||||
<uap:LockScreen Notification="badgeAndTileText" BadgeLogo="assets\BadgeLogo.png" />
|
||||
<uap:DefaultTile Wide310x150Logo="assets\Wide310x150Logo.png" />
|
||||
<uap:SplashScreen Image="assets\SplashScreen.png" />
|
||||
</uap:VisualElements>
|
||||
<Extensions>
|
||||
<uap:Extension Category="windows.protocol">
|
||||
<uap:Protocol Name="bitwarden">
|
||||
<uap:DisplayName>${displayName}</uap:DisplayName>
|
||||
</uap:Protocol>
|
||||
</uap:Extension>
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
<com:ExeServer Executable='${executable}'
|
||||
DisplayName="Bitwarden Passkey Manager">
|
||||
<com:Class Id="0f7dc5d9-69ce-4652-8572-6877fd695062"
|
||||
DisplayName="Bitwarden Passkey Manager" />
|
||||
</com:ExeServer>
|
||||
</com:ComServer>
|
||||
</com:Extension>
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
</Package>
|
||||
1583
apps/desktop/desktop_native/Cargo.lock
generated
1583
apps/desktop/desktop_native/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -77,9 +77,9 @@ tracing-subscriber = { version = "=0.3.20", features = [
|
||||
typenum = "=1.18.0"
|
||||
uniffi = "=0.28.3"
|
||||
widestring = "=1.2.0"
|
||||
windows = "=0.61.1"
|
||||
windows-core = "=0.61.0"
|
||||
windows-future = "=0.2.0"
|
||||
windows = { version = "=0.62.2", features = ["Win32_System_Threading"] }
|
||||
windows-core = "=0.62.2"
|
||||
windows-future = "=0.3.2"
|
||||
windows-registry = "=0.6.1"
|
||||
zbus = "=5.11.0"
|
||||
zbus_polkit = "=5.0.0"
|
||||
|
||||
@@ -34,14 +34,15 @@ function buildNapiModule(target, release = true) {
|
||||
function buildProxyBin(target, release = true) {
|
||||
const targetArg = target ? `--target ${target}` : "";
|
||||
const releaseArg = release ? "--release" : "";
|
||||
child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")});
|
||||
const xwin = target && target.includes('windows') && process.platform !== "win32" ? "xwin" : "";
|
||||
child_process.execSync(`cargo ${xwin} build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")});
|
||||
|
||||
if (target) {
|
||||
// Copy the resulting binary to the dist folder
|
||||
const targetFolder = release ? "release" : "debug";
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
const nodeArch = rustTargetsMap[target].nodeArch;
|
||||
fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`));
|
||||
const { nodeArch, platform } = rustTargetsMap[target];
|
||||
const ext = platform === "win32" ? ".exe" : "";
|
||||
fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${platform}-${nodeArch}${ext}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ windows = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security_Credentials",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_WinRT",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
|
||||
@@ -4,3 +4,125 @@
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
mod autofill;
|
||||
pub use autofill::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RunCommandRequest {
|
||||
#[serde(rename = "namespace")]
|
||||
namespace: String,
|
||||
#[serde(rename = "command")]
|
||||
command: RunCommand,
|
||||
#[serde(rename = "params")]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum RunCommand {
|
||||
#[serde(rename = "status")]
|
||||
Status,
|
||||
#[serde(rename = "sync")]
|
||||
Sync,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SyncParameters {
|
||||
#[serde(rename = "credentials")]
|
||||
pub(crate) credentials: Vec<SyncCredential>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum SyncCredential {
|
||||
#[serde(rename = "login")]
|
||||
Login {
|
||||
#[serde(rename = "cipherId")]
|
||||
cipher_id: String,
|
||||
password: String,
|
||||
uri: String,
|
||||
username: String,
|
||||
},
|
||||
#[serde(rename = "fido2")]
|
||||
Fido2 {
|
||||
#[serde(rename = "cipherId")]
|
||||
cipher_id: String,
|
||||
|
||||
#[serde(rename = "rpId")]
|
||||
rp_id: String,
|
||||
|
||||
/// Base64-encoded
|
||||
#[serde(rename = "credentialId")]
|
||||
credential_id: String,
|
||||
|
||||
#[serde(rename = "userName")]
|
||||
user_name: String,
|
||||
|
||||
/// Base64-encoded
|
||||
#[serde(rename = "userHandle")]
|
||||
user_handle: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusResponse {
|
||||
support: StatusSupport,
|
||||
state: StatusState,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusSupport {
|
||||
fido2: bool,
|
||||
password: bool,
|
||||
#[serde(rename = "incrementalUpdates")]
|
||||
incremental_updates: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusState {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SyncResponse {
|
||||
added: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum CommandResponse {
|
||||
#[serde(rename = "success")]
|
||||
Success { value: Value },
|
||||
#[serde(rename = "error")]
|
||||
Error { error: String },
|
||||
}
|
||||
|
||||
impl From<anyhow::Result<Value>> for CommandResponse {
|
||||
fn from(value: anyhow::Result<Value>) -> Self {
|
||||
match value {
|
||||
Ok(response) => Self::Success { value: response },
|
||||
Err(err) => Self::Error {
|
||||
error: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<StatusResponse> for CommandResponse {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(response: StatusResponse) -> Result<Self, anyhow::Error> {
|
||||
Ok(Self::Success {
|
||||
value: serde_json::to_value(response)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SyncResponse> for CommandResponse {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(response: SyncResponse) -> Result<Self, anyhow::Error> {
|
||||
Ok(Self::Success {
|
||||
value: serde_json::to_value(response)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,625 @@
|
||||
use anyhow::Result;
|
||||
use std::alloc;
|
||||
use std::mem::{align_of, MaybeUninit};
|
||||
use std::ptr::NonNull;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use windows::core::s;
|
||||
use windows::Win32::Foundation::FreeLibrary;
|
||||
use windows::{
|
||||
core::{GUID, HRESULT, PCSTR},
|
||||
Win32::System::{Com::CoTaskMemAlloc, LibraryLoader::*},
|
||||
};
|
||||
|
||||
use crate::autofill::{
|
||||
CommandResponse, RunCommand, RunCommandRequest, StatusResponse, StatusState, StatusSupport,
|
||||
SyncCredential, SyncParameters, SyncResponse,
|
||||
};
|
||||
|
||||
const PLUGIN_CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062";
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn run_command(_value: String) -> Result<String> {
|
||||
todo!("Windows does not support autofill");
|
||||
pub async fn run_command(value: String) -> Result<String> {
|
||||
// this.logService.info("Passkey request received:", { error, event });
|
||||
|
||||
let request: RunCommandRequest = serde_json::from_str(&value)
|
||||
.map_err(|e| anyhow!("Failed to deserialize passkey request: {e}"))?;
|
||||
|
||||
if request.namespace != "autofill" {
|
||||
return Err(anyhow!("Unknown namespace: {}", request.namespace));
|
||||
}
|
||||
let response: CommandResponse = match request.command {
|
||||
RunCommand::Status => handle_status_request()?.try_into()?,
|
||||
RunCommand::Sync => {
|
||||
let params: SyncParameters = serde_json::from_value(request.params)
|
||||
.map_err(|e| anyhow!("Could not parse sync parameters: {e}"))?;
|
||||
handle_sync_request(params)?.try_into()?
|
||||
}
|
||||
};
|
||||
serde_json::to_string(&response).map_err(|e| anyhow!("Failed to serialize response: {e}"))
|
||||
|
||||
/*
|
||||
try {
|
||||
const request = JSON.parse(event.requestJson);
|
||||
this.logService.info("Parsed passkey request:", { type: event.requestType, request });
|
||||
|
||||
// Handle different request types based on the requestType field
|
||||
switch (event.requestType) {
|
||||
case "assertion":
|
||||
return await this.handleAssertionRequest(request);
|
||||
case "registration":
|
||||
return await this.handleRegistrationRequest(request);
|
||||
case "sync":
|
||||
return await this.handleSyncRequest(request);
|
||||
default:
|
||||
this.logService.error("Unknown passkey request type:", event.requestType);
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: `Unknown request type: ${event.requestType}`,
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
this.logService.error("Failed to parse passkey request:", parseError);
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: "Failed to parse request JSON",
|
||||
});
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
fn handle_sync_request(params: SyncParameters) -> Result<SyncResponse> {
|
||||
let credentials: Vec<SyncedCredential> = params
|
||||
.credentials
|
||||
.into_iter()
|
||||
.filter_map(|c| c.try_into().ok())
|
||||
.collect();
|
||||
let num_creds = credentials.len().try_into().unwrap_or(u32::MAX);
|
||||
sync_credentials_to_windows(credentials, PLUGIN_CLSID)
|
||||
.map_err(|e| anyhow!("Failed to sync credentials to Windows: {e}"))?;
|
||||
Ok(SyncResponse { added: num_creds })
|
||||
/*
|
||||
let mut log_file = std::fs::File::options()
|
||||
.append(true)
|
||||
.open("C:\\temp\\bitwarden_windows_core.log")
|
||||
.unwrap();
|
||||
log_file.write_all(b"Made it to sync!");
|
||||
*/
|
||||
}
|
||||
|
||||
fn handle_status_request() -> Result<StatusResponse> {
|
||||
Ok(StatusResponse {
|
||||
support: StatusSupport {
|
||||
fido2: true,
|
||||
password: false,
|
||||
incremental_updates: false,
|
||||
},
|
||||
state: StatusState { enabled: true },
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
async fn handleAssertionRequest(request: autofill.PasskeyAssertionRequest): Promise<string> {
|
||||
this.logService.info("Handling assertion request for rpId:", request.rpId);
|
||||
|
||||
try {
|
||||
// Generate unique identifiers for tracking this request
|
||||
const clientId = Date.now();
|
||||
const sequenceNumber = Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Send request and wait for response
|
||||
const response = await this.sendAndOptionallyWait<autofill.PasskeyAssertionResponse>(
|
||||
"autofill.passkeyAssertion",
|
||||
{
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request: request,
|
||||
},
|
||||
{ waitForResponse: true, timeout: 60000 },
|
||||
);
|
||||
|
||||
if (response) {
|
||||
// Convert the response to the format expected by the NAPI bridge
|
||||
return JSON.stringify({
|
||||
type: "assertion_response",
|
||||
...response,
|
||||
});
|
||||
} else {
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: "No response received from renderer",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("Error in assertion request:", error);
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: `Assertion request failed: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRegistrationRequest(
|
||||
request: autofill.PasskeyRegistrationRequest,
|
||||
): Promise<string> {
|
||||
this.logService.info("Handling registration request for rpId:", request.rpId);
|
||||
|
||||
try {
|
||||
// Generate unique identifiers for tracking this request
|
||||
const clientId = Date.now();
|
||||
const sequenceNumber = Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Send request and wait for response
|
||||
const response = await this.sendAndOptionallyWait<autofill.PasskeyRegistrationResponse>(
|
||||
"autofill.passkeyRegistration",
|
||||
{
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request: request,
|
||||
},
|
||||
{ waitForResponse: true, timeout: 60000 },
|
||||
);
|
||||
|
||||
this.logService.info("Received response for registration request:", response);
|
||||
|
||||
if (response) {
|
||||
// Convert the response to the format expected by the NAPI bridge
|
||||
return JSON.stringify({
|
||||
type: "registration_response",
|
||||
...response,
|
||||
});
|
||||
} else {
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: "No response received from renderer",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("Error in registration request:", error);
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: `Registration request failed: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSyncRequest(
|
||||
request: passkey_authenticator.PasskeySyncRequest,
|
||||
): Promise<string> {
|
||||
this.logService.info("Handling sync request for rpId:", request.rpId);
|
||||
|
||||
try {
|
||||
// Generate unique identifiers for tracking this request
|
||||
const clientId = Date.now();
|
||||
const sequenceNumber = Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Send sync request and wait for response
|
||||
const response = await this.sendAndOptionallyWait<passkey_authenticator.PasskeySyncResponse>(
|
||||
"autofill.passkeySync",
|
||||
{
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request: { rpId: request.rpId },
|
||||
},
|
||||
{ waitForResponse: true, timeout: 60000 },
|
||||
);
|
||||
|
||||
this.logService.info("Received response for sync request:", response);
|
||||
|
||||
if (response && response.credentials) {
|
||||
// Convert the response to the format expected by the NAPI bridge
|
||||
return JSON.stringify({
|
||||
type: "sync_response",
|
||||
credentials: response.credentials,
|
||||
});
|
||||
} else {
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: "No credentials received from renderer",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("Error in sync request:", error);
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: `Sync request failed: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
impl TryFrom<SyncCredential> for SyncedCredential {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: SyncCredential) -> Result<Self, anyhow::Error> {
|
||||
if let SyncCredential::Fido2 {
|
||||
rp_id,
|
||||
credential_id,
|
||||
user_name,
|
||||
user_handle,
|
||||
..
|
||||
} = value
|
||||
{
|
||||
Ok(Self {
|
||||
credential_id: URL_SAFE_NO_PAD
|
||||
.decode(credential_id)
|
||||
.map_err(|e| anyhow!("Could not decode credential ID: {e}"))?,
|
||||
rp_id: rp_id,
|
||||
user_name: user_name,
|
||||
user_handle: URL_SAFE_NO_PAD
|
||||
.decode(&user_handle)
|
||||
.map_err(|e| anyhow!("Could not decode user handle: {e}"))?,
|
||||
})
|
||||
} else {
|
||||
Err(anyhow!("Only FIDO2 credentials are supported."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initiates credential sync from Electron to Windows - called when Electron wants to push credentials to Windows
|
||||
fn sync_credentials_to_windows(
|
||||
credentials: Vec<SyncedCredential>,
|
||||
plugin_clsid: &str,
|
||||
) -> Result<(), String> {
|
||||
tracing::debug!(
|
||||
"[SYNC_TO_WIN] sync_credentials_to_windows called with {} credentials for plugin CLSID: {}",
|
||||
credentials.len(),
|
||||
plugin_clsid
|
||||
);
|
||||
|
||||
// Parse CLSID string to GUID
|
||||
let clsid_guid = parse_clsid_to_guid_str(plugin_clsid)
|
||||
.map_err(|e| format!("Failed to parse CLSID: {}", e))?;
|
||||
|
||||
if credentials.is_empty() {
|
||||
tracing::debug!("[SYNC_TO_WIN] No credentials to sync, proceeding with empty sync");
|
||||
}
|
||||
|
||||
// Convert Bitwarden credentials to Windows credential details
|
||||
let mut win_credentials = Vec::new();
|
||||
|
||||
for (i, cred) in credentials.iter().enumerate() {
|
||||
tracing::debug!("[SYNC_TO_WIN] Converting credential {}: RP ID: {}, User: {}, Credential ID: {:?} ({} bytes), User ID: {:?} ({} bytes)",
|
||||
i + 1, cred.rp_id, cred.user_name, &cred.credential_id, cred.credential_id.len(), &cred.user_handle, cred.user_handle.len());
|
||||
|
||||
let win_cred = WebAuthnPluginCredentialDetails::create_from_bytes(
|
||||
cred.credential_id.clone(), // Pass raw bytes
|
||||
cred.rp_id.clone(),
|
||||
cred.rp_id.clone(), // Use RP ID as friendly name for now
|
||||
cred.user_handle.clone(), // Pass raw bytes
|
||||
cred.user_name.clone(),
|
||||
cred.user_name.clone(), // Use user name as display name for now
|
||||
);
|
||||
|
||||
win_credentials.push(win_cred);
|
||||
tracing::debug!(
|
||||
"[SYNC_TO_WIN] Converted credential {} to Windows format",
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
|
||||
// First try to remove all existing credentials for this plugin
|
||||
tracing::debug!("Attempting to remove all existing credentials before sync...");
|
||||
match remove_all_credentials(clsid_guid) {
|
||||
Ok(()) => {
|
||||
tracing::debug!("Successfully removed existing credentials");
|
||||
}
|
||||
Err(e) if e.contains("can't be loaded") => {
|
||||
tracing::debug!("RemoveAllCredentials function not available - this is expected for some Windows versions");
|
||||
// This is fine, the function might not exist in all versions
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Warning: Failed to remove existing credentials: {}", e);
|
||||
// Continue anyway, as this might be the first sync or an older Windows version
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new credentials (only if we have any)
|
||||
if credentials.is_empty() {
|
||||
tracing::debug!("No credentials to add to Windows - sync completed successfully");
|
||||
Ok(())
|
||||
} else {
|
||||
tracing::debug!("Adding new credentials to Windows...");
|
||||
match add_credentials(clsid_guid, win_credentials) {
|
||||
Ok(()) => {
|
||||
tracing::debug!("Successfully synced credentials to Windows");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to add credentials to Windows: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential data for sync operations
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncedCredential {
|
||||
pub credential_id: Vec<u8>,
|
||||
pub rp_id: String,
|
||||
pub user_name: String,
|
||||
pub user_handle: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Represents a credential.
|
||||
/// Header File Name: _WEBAUTHN_PLUGIN_CREDENTIAL_DETAILS
|
||||
/// Header File Usage: WebAuthNPluginAuthenticatorAddCredentials, etc.
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
struct WebAuthnPluginCredentialDetails {
|
||||
pub credential_id_byte_count: u32,
|
||||
pub credential_id_pointer: *const u8, // Changed to const in stable
|
||||
pub rpid: *const u16, // Changed to const (LPCWSTR)
|
||||
pub rp_friendly_name: *const u16, // Changed to const (LPCWSTR)
|
||||
pub user_id_byte_count: u32,
|
||||
pub user_id_pointer: *const u8, // Changed to const
|
||||
pub user_name: *const u16, // Changed to const (LPCWSTR)
|
||||
pub user_display_name: *const u16, // Changed to const (LPCWSTR)
|
||||
}
|
||||
|
||||
impl WebAuthnPluginCredentialDetails {
|
||||
pub fn create_from_bytes(
|
||||
credential_id: Vec<u8>,
|
||||
rpid: String,
|
||||
rp_friendly_name: String,
|
||||
user_id: Vec<u8>,
|
||||
user_name: String,
|
||||
user_display_name: String,
|
||||
) -> Self {
|
||||
// Allocate credential_id bytes with COM
|
||||
let (credential_id_pointer, credential_id_byte_count) =
|
||||
ComBuffer::from_buffer(&credential_id);
|
||||
|
||||
// Allocate user_id bytes with COM
|
||||
let (user_id_pointer, user_id_byte_count) = ComBuffer::from_buffer(&user_id);
|
||||
|
||||
// Convert strings to null-terminated wide strings using trait methods
|
||||
let (rpid_ptr, _) = rpid.to_com_utf16();
|
||||
let (rp_friendly_name_ptr, _) = rp_friendly_name.to_com_utf16();
|
||||
let (user_name_ptr, _) = user_name.to_com_utf16();
|
||||
let (user_display_name_ptr, _) = user_display_name.to_com_utf16();
|
||||
|
||||
Self {
|
||||
credential_id_byte_count,
|
||||
credential_id_pointer: credential_id_pointer as *const u8,
|
||||
rpid: rpid_ptr as *const u16,
|
||||
rp_friendly_name: rp_friendly_name_ptr as *const u16,
|
||||
user_id_byte_count,
|
||||
user_id_pointer: user_id_pointer as *const u8,
|
||||
user_name: user_name_ptr as *const u16,
|
||||
user_display_name: user_display_name_ptr as *const u16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stable API function signatures - now use REFCLSID and flat arrays
|
||||
type WebAuthNPluginAuthenticatorAddCredentialsFnDeclaration = unsafe extern "cdecl" fn(
|
||||
rclsid: *const GUID, // Changed from string to GUID reference
|
||||
cCredentialDetails: u32,
|
||||
pCredentialDetails: *const WebAuthnPluginCredentialDetails, // Flat array, not list
|
||||
) -> HRESULT;
|
||||
|
||||
/// Trait for converting strings to Windows-compatible wide strings using COM allocation
|
||||
pub trait WindowsString {
|
||||
/// Converts to null-terminated UTF-16 using COM allocation
|
||||
fn to_com_utf16(&self) -> (*mut u16, u32);
|
||||
/// Converts to Vec<u16> for temporary use (caller must keep Vec alive)
|
||||
fn to_utf16(&self) -> Vec<u16>;
|
||||
}
|
||||
|
||||
impl WindowsString for str {
|
||||
fn to_com_utf16(&self) -> (*mut u16, u32) {
|
||||
let mut wide_vec: Vec<u16> = self.encode_utf16().collect();
|
||||
wide_vec.push(0); // null terminator
|
||||
let wide_bytes: Vec<u8> = wide_vec.iter().flat_map(|&x| x.to_le_bytes()).collect();
|
||||
let (ptr, byte_count) = ComBuffer::from_buffer(&wide_bytes);
|
||||
(ptr as *mut u16, byte_count)
|
||||
}
|
||||
|
||||
fn to_utf16(&self) -> Vec<u16> {
|
||||
let mut wide_vec: Vec<u16> = self.encode_utf16().collect();
|
||||
wide_vec.push(0); // null terminator
|
||||
wide_vec
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(transparent)]
|
||||
pub struct ComBuffer(NonNull<MaybeUninit<u8>>);
|
||||
|
||||
impl ComBuffer {
|
||||
/// Returns an COM-allocated buffer of `size`.
|
||||
fn alloc(size: usize, for_slice: bool) -> Self {
|
||||
#[expect(clippy::as_conversions)]
|
||||
{
|
||||
assert!(size <= isize::MAX as usize, "requested bad object size");
|
||||
}
|
||||
|
||||
// SAFETY: Any size is valid to pass to Windows, even `0`.
|
||||
let ptr = NonNull::new(unsafe { CoTaskMemAlloc(size) }).unwrap_or_else(|| {
|
||||
// XXX: This doesn't have to be correct, just close enough for an OK OOM error.
|
||||
let layout = alloc::Layout::from_size_align(size, align_of::<u8>()).unwrap();
|
||||
alloc::handle_alloc_error(layout)
|
||||
});
|
||||
|
||||
if for_slice {
|
||||
// Ininitialize the buffer so it can later be treated as `&mut [u8]`.
|
||||
// SAFETY: The pointer is valid and we are using a valid value for a byte-wise allocation.
|
||||
unsafe { ptr.write_bytes(0, size) };
|
||||
}
|
||||
|
||||
Self(ptr.cast())
|
||||
}
|
||||
|
||||
fn into_ptr<T>(self) -> *mut T {
|
||||
self.0.cast().as_ptr()
|
||||
}
|
||||
|
||||
/// Creates a new COM-allocated structure.
|
||||
///
|
||||
/// Note that `T` must be [Copy] to avoid any possible memory leaks.
|
||||
pub fn with_object<T: Copy>(object: T) -> *mut T {
|
||||
// NB: Vendored from Rust's alloc code since we can't yet allocate `Box` with a custom allocator.
|
||||
const MIN_ALIGN: usize = if cfg!(target_pointer_width = "64") {
|
||||
16
|
||||
} else if cfg!(target_pointer_width = "32") {
|
||||
8
|
||||
} else {
|
||||
panic!("unsupported arch")
|
||||
};
|
||||
|
||||
// SAFETY: Validate that our alignment works for a normal size-based allocation for soundness.
|
||||
let layout = const {
|
||||
let layout = alloc::Layout::new::<T>();
|
||||
assert!(layout.align() <= MIN_ALIGN);
|
||||
layout
|
||||
};
|
||||
|
||||
let buffer = Self::alloc(layout.size(), false);
|
||||
// SAFETY: `ptr` is valid for writes of `T` because we correctly allocated the right sized buffer that
|
||||
// accounts for any alignment requirements.
|
||||
//
|
||||
// Additionally, we ensure the value is treated as moved by forgetting the source.
|
||||
unsafe { buffer.0.cast::<T>().write(object) };
|
||||
buffer.into_ptr()
|
||||
}
|
||||
|
||||
pub fn from_buffer<T: AsRef<[u8]>>(buffer: T) -> (*mut u8, u32) {
|
||||
let buffer = buffer.as_ref();
|
||||
let len = buffer.len();
|
||||
let com_buffer = Self::alloc(len, true);
|
||||
|
||||
// SAFETY: `ptr` points to a valid allocation that `len` matches, and we made sure
|
||||
// the bytes were initialized. Additionally, bytes have no alignment requirements.
|
||||
unsafe {
|
||||
NonNull::slice_from_raw_parts(com_buffer.0.cast::<u8>(), len)
|
||||
.as_mut()
|
||||
.copy_from_slice(buffer)
|
||||
}
|
||||
|
||||
// Safety: The Windows API structures these buffers are placed into use `u32` (`DWORD`) to
|
||||
// represent length.
|
||||
#[expect(clippy::as_conversions)]
|
||||
(com_buffer.into_ptr(), len as u32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a CLSID string to a GUID
|
||||
pub(crate) fn parse_clsid_to_guid_str(clsid_str: &str) -> Result<GUID, String> {
|
||||
// Remove hyphens and parse as hex
|
||||
let clsid_clean = clsid_str.replace("-", "");
|
||||
if clsid_clean.len() != 32 {
|
||||
return Err("Invalid CLSID format".to_string());
|
||||
}
|
||||
|
||||
// Convert to u128 and create GUID
|
||||
let clsid_u128 = u128::from_str_radix(&clsid_clean, 16)
|
||||
.map_err(|_| "Failed to parse CLSID as hex".to_string())?;
|
||||
|
||||
Ok(GUID::from_u128(clsid_u128))
|
||||
}
|
||||
|
||||
pub fn remove_all_credentials(clsid_guid: GUID) -> std::result::Result<(), String> {
|
||||
tracing::debug!("Loading WebAuthNPluginAuthenticatorRemoveAllCredentials function...");
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginAuthenticatorRemoveAllCredentialsFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginAuthenticatorRemoveAllCredentials"),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Some(api) => {
|
||||
tracing::debug!("Function loaded successfully, calling API...");
|
||||
|
||||
let result = unsafe { api(&clsid_guid) };
|
||||
|
||||
if result.is_err() {
|
||||
let error_code = result.0;
|
||||
tracing::debug!("API call failed with HRESULT: 0x{:x}", error_code);
|
||||
|
||||
return Err(format!(
|
||||
"Error: Error response from WebAuthNPluginAuthenticatorRemoveAllCredentials()\nHRESULT: 0x{:x}\n{}",
|
||||
error_code, result.message()
|
||||
));
|
||||
}
|
||||
|
||||
tracing::debug!("API call succeeded");
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Failed to load WebAuthNPluginAuthenticatorRemoveAllCredentials function from webauthn.dll");
|
||||
Err(String::from("Error: Can't complete remove_all_credentials(), as the function WebAuthNPluginAuthenticatorRemoveAllCredentials can't be loaded."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe fn delay_load<T>(library: PCSTR, function: PCSTR) -> Option<T> {
|
||||
let library = LoadLibraryExA(library, None, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
|
||||
|
||||
let Ok(library) = library else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let address = GetProcAddress(library, function);
|
||||
|
||||
if address.is_some() {
|
||||
return Some(std::mem::transmute_copy(&address));
|
||||
}
|
||||
|
||||
_ = FreeLibrary(library);
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn add_credentials(
|
||||
clsid_guid: GUID,
|
||||
credentials: Vec<WebAuthnPluginCredentialDetails>,
|
||||
) -> std::result::Result<(), String> {
|
||||
tracing::debug!("Loading WebAuthNPluginAuthenticatorAddCredentials function...");
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginAuthenticatorAddCredentialsFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginAuthenticatorAddCredentials"),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Some(api) => {
|
||||
tracing::debug!("Function loaded successfully, calling API...");
|
||||
tracing::debug!("Adding {} credentials", credentials.len());
|
||||
|
||||
let credential_count = credentials.len() as u32;
|
||||
let credentials_ptr = if credentials.is_empty() {
|
||||
std::ptr::null()
|
||||
} else {
|
||||
credentials.as_ptr()
|
||||
};
|
||||
|
||||
let result = unsafe { api(&clsid_guid, credential_count, credentials_ptr) };
|
||||
|
||||
if result.is_err() {
|
||||
let error_code = result.0;
|
||||
tracing::debug!("API call failed with HRESULT: 0x{:x}", error_code);
|
||||
return Err(format!(
|
||||
"Error: Error response from WebAuthNPluginAuthenticatorAddCredentials()\nHRESULT: 0x{:x}\n{}",
|
||||
error_code, result.message()
|
||||
));
|
||||
}
|
||||
|
||||
tracing::debug!("API call succeeded");
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Failed to load WebAuthNPluginAuthenticatorAddCredentials function from webauthn.dll");
|
||||
Err(String::from("Error: Can't complete add_credentials(), as the function WebAuthNPluginAuthenticatorAddCredentials can't be loaded."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WebAuthNPluginAuthenticatorRemoveAllCredentialsFnDeclaration =
|
||||
unsafe extern "cdecl" fn(rclsid: *const GUID) -> HRESULT;
|
||||
|
||||
@@ -48,7 +48,7 @@ impl super::BiometricTrait for Biometric {
|
||||
let operation: IAsyncOperation<UserConsentVerificationResult> = unsafe {
|
||||
interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
|
||||
};
|
||||
let result = operation.get()?;
|
||||
let result = operation.join()?;
|
||||
|
||||
match result {
|
||||
UserConsentVerificationResult::Verified => Ok(true),
|
||||
@@ -57,7 +57,7 @@ impl super::BiometricTrait for Biometric {
|
||||
}
|
||||
|
||||
async fn available() -> Result<bool> {
|
||||
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?;
|
||||
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.join()?;
|
||||
|
||||
match ucv_available {
|
||||
UserConsentVerifierAvailability::Available => Ok(true),
|
||||
|
||||
35
apps/desktop/desktop_native/macos_provider/README.md
Normal file
35
apps/desktop/desktop_native/macos_provider/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Explainer: Mac OS Native Passkey Provider
|
||||
|
||||
This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context.
|
||||
|
||||
## The high level
|
||||
MacOS has native APIs (similar to iOS) to allow Credential Managers to provide credentials to the MacOS autofill system (in the PR referenced above, we only provide passkeys).
|
||||
|
||||
We’ve written a Swift-based native autofill-extension. It’s bundled in the app-bundle in PlugIns, similar to the safari-extension.
|
||||
|
||||
This swift extension currently communicates with our Electron app through IPC based on a unix socket. The IPC implementation is done in Rust and utilized through UniFFI + NAPI bindings.
|
||||
|
||||
Footnotes:
|
||||
|
||||
* We're not using the IPC framework as the implementation pre-dates the IPC framework.
|
||||
* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed.
|
||||
|
||||
Electron receives the messages and passes it to Angular (through the electron-renderer event system).
|
||||
|
||||
Our existing fido2 services in the renderer respond to events, displaying UI as necessary, and returns the signature back through the same mechanism, allowing people to authenticate with passkeys through the native system + UI. See [Mac OS Native Passkey Workflows](https://bitwarden.atlassian.net/wiki/spaces/EN/pages/1828356098/Mac+OS+Native+Passkey+Workflows) for demo videos.
|
||||
|
||||
## Typescript + UI implementations
|
||||
|
||||
We utilize the same FIDO2 implementation and interface that is already present for our browser authentication. It was designed by @coroiu with multiple ‘ui environments' in mind.
|
||||
|
||||
Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors.
|
||||
|
||||
We’ve also implemented a couple FIDO2 UI components to handle registration/sign in flows, but also improved the “modal mode” of the desktop app.
|
||||
|
||||
## Modal mode
|
||||
|
||||
When modal mode is activated, the desktop app turns into a smaller modal that is always on top and cannot be resized. This is done to improve the UX of performing a passkey operation (or SSH operation). Once the operation is completed, the app returns to normal mode and its previous position.
|
||||
|
||||
We are not using electron modal windows, for a couple reason. It would require us to send data in yet another layer of IPC, but also because we'd need to bootstrap entire renderer/app instead of reusing the existing window.
|
||||
|
||||
Some modal modes may hide the 'traffic buttons' (window controls) due to design requirements.
|
||||
@@ -8,6 +8,9 @@ rm -r tmp
|
||||
mkdir -p ./tmp/target/universal-darwin/release/
|
||||
|
||||
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-apple-darwin
|
||||
|
||||
cargo build --package macos_provider --target aarch64-apple-darwin --release
|
||||
cargo build --package macos_provider --target x86_64-apple-darwin --release
|
||||
|
||||
|
||||
@@ -56,6 +56,14 @@ trait Callback: Send + Sync {
|
||||
fn error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
#[derive(uniffi::Enum, Debug)]
|
||||
/// Store the connection status between the macOS credential provider extension
|
||||
/// and the desktop application's IPC server.
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct MacOSProviderClient {
|
||||
to_server_send: tokio::sync::mpsc::Sender<String>,
|
||||
@@ -64,8 +72,23 @@ pub struct MacOSProviderClient {
|
||||
response_callbacks_counter: AtomicU32,
|
||||
#[allow(clippy::type_complexity)]
|
||||
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
|
||||
|
||||
// Flag to track connection status - atomic for thread safety without locks
|
||||
connection_status: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
/// Store native desktop status information to use for IPC communication
|
||||
/// between the application and the macOS credential provider.
|
||||
pub struct NativeStatus {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
// In our callback management, 0 is a reserved sequence number indicating that a message does not have a callback.
|
||||
const NO_CALLBACK_INDICATOR: u32 = 0;
|
||||
|
||||
#[uniffi::export]
|
||||
impl MacOSProviderClient {
|
||||
// FIXME: Remove unwraps! They panic and terminate the whole application.
|
||||
@@ -92,13 +115,15 @@ impl MacOSProviderClient {
|
||||
|
||||
let client = MacOSProviderClient {
|
||||
to_server_send,
|
||||
response_callbacks_counter: AtomicU32::new(0),
|
||||
response_callbacks_counter: AtomicU32::new(1), // Start at 1 since 0 is reserved for "no callback" scenarios
|
||||
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
|
||||
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
let path = desktop_core::ipc::path("af");
|
||||
|
||||
let queue = client.response_callbacks_queue.clone();
|
||||
let connection_status = client.connection_status.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
@@ -116,9 +141,11 @@ impl MacOSProviderClient {
|
||||
match serde_json::from_str::<SerializedMessage>(&message) {
|
||||
Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
|
||||
info!("Connected to server");
|
||||
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
|
||||
info!("Disconnected from server");
|
||||
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(SerializedMessage::Message {
|
||||
sequence_number,
|
||||
@@ -156,12 +183,17 @@ impl MacOSProviderClient {
|
||||
client
|
||||
}
|
||||
|
||||
pub fn send_native_status(&self, key: String, value: String) {
|
||||
let status = NativeStatus { key, value };
|
||||
self.send_message(status, None);
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_registration(
|
||||
&self,
|
||||
request: PasskeyRegistrationRequest,
|
||||
callback: Arc<dyn PreparePasskeyRegistrationCallback>,
|
||||
) {
|
||||
self.send_message(request, Box::new(callback));
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_assertion(
|
||||
@@ -169,7 +201,7 @@ impl MacOSProviderClient {
|
||||
request: PasskeyAssertionRequest,
|
||||
callback: Arc<dyn PreparePasskeyAssertionCallback>,
|
||||
) {
|
||||
self.send_message(request, Box::new(callback));
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_assertion_without_user_interface(
|
||||
@@ -177,7 +209,18 @@ impl MacOSProviderClient {
|
||||
request: PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
callback: Arc<dyn PreparePasskeyAssertionCallback>,
|
||||
) {
|
||||
self.send_message(request, Box::new(callback));
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn get_connection_status(&self) -> ConnectionStatus {
|
||||
let is_connected = self
|
||||
.connection_status
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if is_connected {
|
||||
ConnectionStatus::Connected
|
||||
} else {
|
||||
ConnectionStatus::Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +242,6 @@ enum SerializedMessage {
|
||||
}
|
||||
|
||||
impl MacOSProviderClient {
|
||||
// FIXME: Remove unwraps! They panic and terminate the whole application.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
|
||||
let sequence_number = self
|
||||
@@ -208,20 +250,23 @@ impl MacOSProviderClient {
|
||||
|
||||
self.response_callbacks_queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.expect("response callbacks queue mutex should not be poisoned")
|
||||
.insert(sequence_number, (callback, Instant::now()));
|
||||
|
||||
sequence_number
|
||||
}
|
||||
|
||||
// FIXME: Remove unwraps! They panic and terminate the whole application.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn send_message(
|
||||
&self,
|
||||
message: impl Serialize + DeserializeOwned,
|
||||
callback: Box<dyn Callback>,
|
||||
callback: Option<Box<dyn Callback>>,
|
||||
) {
|
||||
let sequence_number = self.add_callback(callback);
|
||||
let sequence_number = if let Some(callback) = callback {
|
||||
self.add_callback(callback)
|
||||
} else {
|
||||
NO_CALLBACK_INDICATOR
|
||||
};
|
||||
|
||||
let message = serde_json::to_string(&SerializedMessage::Message {
|
||||
sequence_number,
|
||||
@@ -231,15 +276,17 @@ impl MacOSProviderClient {
|
||||
|
||||
if let Err(e) = self.to_server_send.blocking_send(message) {
|
||||
// Make sure we remove the callback from the queue if we can't send the message
|
||||
if let Some((cb, _)) = self
|
||||
.response_callbacks_queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&sequence_number)
|
||||
{
|
||||
cb.error(BitwardenError::Internal(format!(
|
||||
"Error sending message: {e}"
|
||||
)));
|
||||
if sequence_number != NO_CALLBACK_INDICATOR {
|
||||
if let Some((callback, _)) = self
|
||||
.response_callbacks_queue
|
||||
.lock()
|
||||
.expect("response callbacks queue mutex should not be poisoned")
|
||||
.remove(&sequence_number)
|
||||
{
|
||||
callback.error(BitwardenError::Internal(format!(
|
||||
"Error sending message: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct PasskeyRegistrationRequest {
|
||||
user_verification: UserVerification,
|
||||
supported_algorithms: Vec<i32>,
|
||||
window_xy: Position,
|
||||
excluded_credentials: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record, Serialize, Deserialize)]
|
||||
|
||||
@@ -20,6 +20,7 @@ base64 = { workspace = true }
|
||||
chromium_importer = { path = "../chromium_importer" }
|
||||
desktop_core = { path = "../core" }
|
||||
hex = { workspace = true }
|
||||
log = { workspace = true }
|
||||
napi = { workspace = true, features = ["async"] }
|
||||
napi-derive = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
11
apps/desktop/desktop_native/napi/index.d.ts
vendored
11
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -158,6 +158,7 @@ export declare namespace autofill {
|
||||
userVerification: UserVerification
|
||||
supportedAlgorithms: Array<number>
|
||||
windowXy: Position
|
||||
excludedCredentials: Array<Array<number>>
|
||||
}
|
||||
export interface PasskeyRegistrationResponse {
|
||||
rpId: string
|
||||
@@ -175,13 +176,17 @@ export declare namespace autofill {
|
||||
export interface PasskeyAssertionWithoutUserInterfaceRequest {
|
||||
rpId: string
|
||||
credentialId: Array<number>
|
||||
userName: string
|
||||
userHandle: Array<number>
|
||||
userName?: string
|
||||
userHandle?: Array<number>
|
||||
recordIdentifier?: string
|
||||
clientDataHash: Array<number>
|
||||
userVerification: UserVerification
|
||||
windowXy: Position
|
||||
}
|
||||
export interface NativeStatus {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
export interface PasskeyAssertionResponse {
|
||||
rpId: string
|
||||
userHandle: Array<number>
|
||||
@@ -197,7 +202,7 @@ export declare namespace autofill {
|
||||
* @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client.
|
||||
* @param callback This function will be called whenever a message is received from a client.
|
||||
*/
|
||||
static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void): Promise<IpcServer>
|
||||
static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void): Promise<IpcServer>
|
||||
/** Return the path to the IPC server. */
|
||||
getPath(): string
|
||||
/** Stop the IPC server. */
|
||||
|
||||
@@ -682,6 +682,7 @@ pub mod autofill {
|
||||
pub user_verification: UserVerification,
|
||||
pub supported_algorithms: Vec<i32>,
|
||||
pub window_xy: Position,
|
||||
pub excluded_credentials: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
@@ -712,14 +713,22 @@ pub mod autofill {
|
||||
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
|
||||
pub rp_id: String,
|
||||
pub credential_id: Vec<u8>,
|
||||
pub user_name: String,
|
||||
pub user_handle: Vec<u8>,
|
||||
pub user_name: Option<String>,
|
||||
pub user_handle: Option<Vec<u8>>,
|
||||
pub record_identifier: Option<String>,
|
||||
pub client_data_hash: Vec<u8>,
|
||||
pub user_verification: UserVerification,
|
||||
pub window_xy: Position,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeStatus {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -772,6 +781,13 @@ pub mod autofill {
|
||||
(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest),
|
||||
ErrorStrategy::CalleeHandled,
|
||||
>,
|
||||
#[napi(
|
||||
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void"
|
||||
)]
|
||||
native_status_callback: ThreadsafeFunction<
|
||||
(u32, u32, NativeStatus),
|
||||
ErrorStrategy::CalleeHandled,
|
||||
>,
|
||||
) -> napi::Result<Self> {
|
||||
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
|
||||
tokio::spawn(async move {
|
||||
@@ -844,6 +860,21 @@ pub mod autofill {
|
||||
}
|
||||
}
|
||||
|
||||
match serde_json::from_str::<PasskeyMessage<NativeStatus>>(&message) {
|
||||
Ok(msg) => {
|
||||
let value = msg
|
||||
.value
|
||||
.map(|value| (client_id, msg.sequence_number, value))
|
||||
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
|
||||
native_status_callback
|
||||
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, "Unable to deserialze native status.");
|
||||
}
|
||||
}
|
||||
|
||||
error!(message, "Received an unknown message2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
use anyhow::{bail, Result};
|
||||
use napi::threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction};
|
||||
|
||||
// Use the PasskeyRequestEvent from the parent module
|
||||
pub use crate::passkey_authenticator::{PasskeyRequestEvent, SyncedCredential};
|
||||
|
||||
pub fn register() -> Result<()> {
|
||||
bail!("Not implemented")
|
||||
}
|
||||
|
||||
pub async fn on_request(
|
||||
_callback: ThreadsafeFunction<PasskeyRequestEvent, CalleeHandled>,
|
||||
) -> napi::Result<String> {
|
||||
Err(napi::Error::from_reason(
|
||||
"Passkey authenticator is not supported on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn sync_credentials_to_windows(_credentials: Vec<SyncedCredential>) -> napi::Result<()> {
|
||||
Err(napi::Error::from_reason(
|
||||
"Windows credential sync not supported on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_credentials_from_windows() -> napi::Result<Vec<SyncedCredential>> {
|
||||
Err(napi::Error::from_reason(
|
||||
"Windows credential retrieval not supported on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -14,40 +14,64 @@ void runSync(void* context, NSDictionary *params) {
|
||||
|
||||
// Map credentials to ASPasswordCredential objects
|
||||
NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count];
|
||||
|
||||
for (NSDictionary *credential in credentials) {
|
||||
NSString *type = credential[@"type"];
|
||||
|
||||
if ([type isEqualToString:@"password"]) {
|
||||
NSString *cipherId = credential[@"cipherId"];
|
||||
NSString *uri = credential[@"uri"];
|
||||
NSString *username = credential[@"username"];
|
||||
|
||||
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
|
||||
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
|
||||
ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc]
|
||||
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
|
||||
|
||||
[mappedCredentials addObject:credential];
|
||||
}
|
||||
|
||||
if (@available(macos 14, *)) {
|
||||
if ([type isEqualToString:@"fido2"]) {
|
||||
@try {
|
||||
NSString *type = credential[@"type"];
|
||||
|
||||
if ([type isEqualToString:@"password"]) {
|
||||
NSString *cipherId = credential[@"cipherId"];
|
||||
NSString *rpId = credential[@"rpId"];
|
||||
NSString *userName = credential[@"userName"];
|
||||
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
|
||||
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
|
||||
NSString *uri = credential[@"uri"];
|
||||
NSString *username = credential[@"username"];
|
||||
|
||||
// Skip credentials with null username since MacOS crashes if we send credentials with empty usernames
|
||||
if ([username isKindOfClass:[NSNull class]] || username.length == 0) {
|
||||
NSLog(@"Skipping credential, username is empty: %@", credential);
|
||||
continue;
|
||||
}
|
||||
|
||||
Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity");
|
||||
id credential = [[passkeyCredentialIdentityClass alloc]
|
||||
initWithRelyingPartyIdentifier:rpId
|
||||
userName:userName
|
||||
credentialID:credentialId
|
||||
userHandle:userHandle
|
||||
recordIdentifier:cipherId];
|
||||
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
|
||||
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
|
||||
ASPasswordCredentialIdentity *passwordIdentity = [[ASPasswordCredentialIdentity alloc]
|
||||
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
|
||||
|
||||
[mappedCredentials addObject:credential];
|
||||
[mappedCredentials addObject:passwordIdentity];
|
||||
}
|
||||
else if (@available(macos 14, *)) {
|
||||
// Fido2CredentialView uses `userName` (camelCase) while Login uses `username`.
|
||||
// This is intentional. Fido2 fields are flattened from the FIDO2 spec's nested structure
|
||||
// (user.name -> userName, rp.id -> rpId) to maintain a clear distinction between these fields.
|
||||
if ([type isEqualToString:@"fido2"]) {
|
||||
NSString *cipherId = credential[@"cipherId"];
|
||||
NSString *rpId = credential[@"rpId"];
|
||||
NSString *userName = credential[@"userName"];
|
||||
|
||||
// Skip credentials with null username since MacOS crashes if we send credentials with empty usernames
|
||||
if ([userName isKindOfClass:[NSNull class]] || userName.length == 0) {
|
||||
NSLog(@"Skipping credential, username is empty: %@", credential);
|
||||
continue;
|
||||
}
|
||||
|
||||
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
|
||||
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
|
||||
|
||||
Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity");
|
||||
id passkeyIdentity = [[passkeyCredentialIdentityClass alloc]
|
||||
initWithRelyingPartyIdentifier:rpId
|
||||
userName:userName
|
||||
credentialID:credentialId
|
||||
userHandle:userHandle
|
||||
recordIdentifier:cipherId];
|
||||
|
||||
[mappedCredentials addObject:passkeyIdentity];
|
||||
}
|
||||
}
|
||||
} @catch (NSException *exception) {
|
||||
// Silently skip any credential that causes an exception
|
||||
// to make sure we don't fail the entire sync
|
||||
// There is likely some invalid data in the credential, and not something the user should/could be asked to correct.
|
||||
NSLog(@"ERROR: Exception processing credential: %@ - %@", exception.name, exception.reason);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,26 @@ NSString *serializeJson(NSDictionary *dictionary, NSError *error) {
|
||||
}
|
||||
|
||||
NSData *decodeBase64URL(NSString *base64URLString) {
|
||||
if (base64URLString.length == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Replace URL-safe characters with standard base64 characters
|
||||
NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"];
|
||||
base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"];
|
||||
|
||||
|
||||
// Add padding if needed
|
||||
// Base 64 strings should be a multiple of 4 in length
|
||||
NSUInteger paddingLength = 4 - (base64String.length % 4);
|
||||
if (paddingLength < 4) {
|
||||
NSMutableString *paddedString = [NSMutableString stringWithString:base64String];
|
||||
for (NSUInteger i = 0; i < paddingLength; i++) {
|
||||
[paddedString appendString:@"="];
|
||||
}
|
||||
base64String = paddedString;
|
||||
}
|
||||
|
||||
// Decode the string
|
||||
NSData *nsdataFromBase64String = [[NSData alloc]
|
||||
initWithBase64EncodedString:base64String options:0];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.85.0"
|
||||
channel = "1.87.0"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
|
||||
@@ -6,6 +6,8 @@ license = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
desktop_core = { path = "../core" }
|
||||
futures = { workspace = true }
|
||||
windows = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
@@ -14,6 +16,15 @@ windows = { workspace = true, features = [
|
||||
] }
|
||||
windows-core = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
ciborium = "0.2"
|
||||
sha2 = "0.10"
|
||||
tokio = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
|
||||
|
||||
/* this ALWAYS GENERATED file contains the definitions for the interfaces */
|
||||
|
||||
|
||||
/* File created by MIDL compiler version 8.01.0628 */
|
||||
/* @@MIDL_FILE_HEADING( ) */
|
||||
|
||||
|
||||
|
||||
/* verify that the <rpcndr.h> version is high enough to compile this file*/
|
||||
#ifndef __REQUIRED_RPCNDR_H_VERSION__
|
||||
#define __REQUIRED_RPCNDR_H_VERSION__ 501
|
||||
#endif
|
||||
|
||||
/* verify that the <rpcsal.h> version is high enough to compile this file*/
|
||||
#ifndef __REQUIRED_RPCSAL_H_VERSION__
|
||||
#define __REQUIRED_RPCSAL_H_VERSION__ 100
|
||||
#endif
|
||||
|
||||
#include "rpc.h"
|
||||
#include "rpcndr.h"
|
||||
|
||||
#ifndef __RPCNDR_H_VERSION__
|
||||
#error this stub requires an updated version of <rpcndr.h>
|
||||
#endif /* __RPCNDR_H_VERSION__ */
|
||||
|
||||
#ifndef COM_NO_WINDOWS_H
|
||||
#include "windows.h"
|
||||
#include "ole2.h"
|
||||
#endif /*COM_NO_WINDOWS_H*/
|
||||
|
||||
#ifndef __pluginauthenticator_h__
|
||||
#define __pluginauthenticator_h__
|
||||
|
||||
#if defined(_MSC_VER) && (_MSC_VER >= 1020)
|
||||
#pragma once
|
||||
#endif
|
||||
|
||||
#ifndef DECLSPEC_XFGVIRT
|
||||
#if defined(_CONTROL_FLOW_GUARD_XFG)
|
||||
#define DECLSPEC_XFGVIRT(base, func) __declspec(xfg_virtual(base, func))
|
||||
#else
|
||||
#define DECLSPEC_XFGVIRT(base, func)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* Forward Declarations */
|
||||
|
||||
#ifndef __IPluginAuthenticator_FWD_DEFINED__
|
||||
#define __IPluginAuthenticator_FWD_DEFINED__
|
||||
typedef interface IPluginAuthenticator IPluginAuthenticator;
|
||||
|
||||
#endif /* __IPluginAuthenticator_FWD_DEFINED__ */
|
||||
|
||||
|
||||
/* header files for imported files */
|
||||
#include "oaidl.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"{
|
||||
#endif
|
||||
|
||||
|
||||
/* interface __MIDL_itf_pluginauthenticator_0000_0000 */
|
||||
/* [local] */
|
||||
|
||||
typedef
|
||||
enum _WEBAUTHN_PLUGIN_REQUEST_TYPE
|
||||
{
|
||||
WEBAUTHN_PLUGIN_REQUEST_TYPE_CTAP2_CBOR = 0x1
|
||||
} WEBAUTHN_PLUGIN_REQUEST_TYPE;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_OPERATION_REQUEST
|
||||
{
|
||||
HWND hWnd;
|
||||
GUID transactionId;
|
||||
DWORD cbRequestSignature;
|
||||
/* [size_is] */ byte *pbRequestSignature;
|
||||
WEBAUTHN_PLUGIN_REQUEST_TYPE requestType;
|
||||
DWORD cbEncodedRequest;
|
||||
/* [size_is] */ byte *pbEncodedRequest;
|
||||
} WEBAUTHN_PLUGIN_OPERATION_REQUEST;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_OPERATION_REQUEST *PWEBAUTHN_PLUGIN_OPERATION_REQUEST;
|
||||
|
||||
typedef const WEBAUTHN_PLUGIN_OPERATION_REQUEST *PCWEBAUTHN_PLUGIN_OPERATION_REQUEST;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_OPERATION_RESPONSE
|
||||
{
|
||||
DWORD cbEncodedResponse;
|
||||
/* [size_is] */ byte *pbEncodedResponse;
|
||||
} WEBAUTHN_PLUGIN_OPERATION_RESPONSE;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_OPERATION_RESPONSE *PWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
|
||||
|
||||
typedef const WEBAUTHN_PLUGIN_OPERATION_RESPONSE *PCWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST
|
||||
{
|
||||
GUID transactionId;
|
||||
DWORD cbRequestSignature;
|
||||
/* [size_is] */ byte *pbRequestSignature;
|
||||
} WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *PWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
|
||||
|
||||
typedef const WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
|
||||
|
||||
typedef
|
||||
enum _PLUGIN_LOCK_STATUS
|
||||
{
|
||||
PluginLocked = 0,
|
||||
PluginUnlocked = ( PluginLocked + 1 )
|
||||
} PLUGIN_LOCK_STATUS;
|
||||
|
||||
|
||||
|
||||
extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_c_ifspec;
|
||||
extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_s_ifspec;
|
||||
|
||||
#ifndef __IPluginAuthenticator_INTERFACE_DEFINED__
|
||||
#define __IPluginAuthenticator_INTERFACE_DEFINED__
|
||||
|
||||
/* interface IPluginAuthenticator */
|
||||
/* [ref][version][uuid][object] */
|
||||
|
||||
|
||||
EXTERN_C const IID IID_IPluginAuthenticator;
|
||||
|
||||
#if defined(__cplusplus) && !defined(CINTERFACE)
|
||||
|
||||
MIDL_INTERFACE("d26bcf6f-b54c-43ff-9f06-d5bf148625f7")
|
||||
IPluginAuthenticator : public IUnknown
|
||||
{
|
||||
public:
|
||||
virtual HRESULT STDMETHODCALLTYPE MakeCredential(
|
||||
/* [in] */ __RPC__in PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
|
||||
/* [retval][out] */ __RPC__out PWEBAUTHN_PLUGIN_OPERATION_RESPONSE response) = 0;
|
||||
|
||||
virtual HRESULT STDMETHODCALLTYPE GetAssertion(
|
||||
/* [in] */ __RPC__in PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
|
||||
/* [retval][out] */ __RPC__out PWEBAUTHN_PLUGIN_OPERATION_RESPONSE response) = 0;
|
||||
|
||||
virtual HRESULT STDMETHODCALLTYPE CancelOperation(
|
||||
/* [in] */ __RPC__in PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request) = 0;
|
||||
|
||||
virtual HRESULT STDMETHODCALLTYPE GetLockStatus(
|
||||
/* [retval][out] */ __RPC__out PLUGIN_LOCK_STATUS *lockStatus) = 0;
|
||||
|
||||
};
|
||||
|
||||
|
||||
#else /* C style interface */
|
||||
|
||||
typedef struct IPluginAuthenticatorVtbl
|
||||
{
|
||||
BEGIN_INTERFACE
|
||||
|
||||
DECLSPEC_XFGVIRT(IUnknown, QueryInterface)
|
||||
HRESULT ( STDMETHODCALLTYPE *QueryInterface )(
|
||||
__RPC__in IPluginAuthenticator * This,
|
||||
/* [in] */ __RPC__in REFIID riid,
|
||||
/* [annotation][iid_is][out] */
|
||||
_COM_Outptr_ void **ppvObject);
|
||||
|
||||
DECLSPEC_XFGVIRT(IUnknown, AddRef)
|
||||
ULONG ( STDMETHODCALLTYPE *AddRef )(
|
||||
__RPC__in IPluginAuthenticator * This);
|
||||
|
||||
DECLSPEC_XFGVIRT(IUnknown, Release)
|
||||
ULONG ( STDMETHODCALLTYPE *Release )(
|
||||
__RPC__in IPluginAuthenticator * This);
|
||||
|
||||
DECLSPEC_XFGVIRT(IPluginAuthenticator, MakeCredential)
|
||||
HRESULT ( STDMETHODCALLTYPE *MakeCredential )(
|
||||
__RPC__in IPluginAuthenticator * This,
|
||||
/* [in] */ __RPC__in PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
|
||||
/* [retval][out] */ __RPC__out PWEBAUTHN_PLUGIN_OPERATION_RESPONSE response);
|
||||
|
||||
DECLSPEC_XFGVIRT(IPluginAuthenticator, GetAssertion)
|
||||
HRESULT ( STDMETHODCALLTYPE *GetAssertion )(
|
||||
__RPC__in IPluginAuthenticator * This,
|
||||
/* [in] */ __RPC__in PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
|
||||
/* [retval][out] */ __RPC__out PWEBAUTHN_PLUGIN_OPERATION_RESPONSE response);
|
||||
|
||||
DECLSPEC_XFGVIRT(IPluginAuthenticator, CancelOperation)
|
||||
HRESULT ( STDMETHODCALLTYPE *CancelOperation )(
|
||||
__RPC__in IPluginAuthenticator * This,
|
||||
/* [in] */ __RPC__in PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request);
|
||||
|
||||
DECLSPEC_XFGVIRT(IPluginAuthenticator, GetLockStatus)
|
||||
HRESULT ( STDMETHODCALLTYPE *GetLockStatus )(
|
||||
__RPC__in IPluginAuthenticator * This,
|
||||
/* [retval][out] */ __RPC__out PLUGIN_LOCK_STATUS *lockStatus);
|
||||
|
||||
END_INTERFACE
|
||||
} IPluginAuthenticatorVtbl;
|
||||
|
||||
interface IPluginAuthenticator
|
||||
{
|
||||
CONST_VTBL struct IPluginAuthenticatorVtbl *lpVtbl;
|
||||
};
|
||||
|
||||
|
||||
|
||||
#ifdef COBJMACROS
|
||||
|
||||
|
||||
#define IPluginAuthenticator_QueryInterface(This,riid,ppvObject) \
|
||||
( (This)->lpVtbl -> QueryInterface(This,riid,ppvObject) )
|
||||
|
||||
#define IPluginAuthenticator_AddRef(This) \
|
||||
( (This)->lpVtbl -> AddRef(This) )
|
||||
|
||||
#define IPluginAuthenticator_Release(This) \
|
||||
( (This)->lpVtbl -> Release(This) )
|
||||
|
||||
|
||||
#define IPluginAuthenticator_MakeCredential(This,request,response) \
|
||||
( (This)->lpVtbl -> MakeCredential(This,request,response) )
|
||||
|
||||
#define IPluginAuthenticator_GetAssertion(This,request,response) \
|
||||
( (This)->lpVtbl -> GetAssertion(This,request,response) )
|
||||
|
||||
#define IPluginAuthenticator_CancelOperation(This,request) \
|
||||
( (This)->lpVtbl -> CancelOperation(This,request) )
|
||||
|
||||
#define IPluginAuthenticator_GetLockStatus(This,lockStatus) \
|
||||
( (This)->lpVtbl -> GetLockStatus(This,lockStatus) )
|
||||
|
||||
#endif /* COBJMACROS */
|
||||
|
||||
|
||||
#endif /* C style interface */
|
||||
|
||||
|
||||
|
||||
|
||||
#endif /* __IPluginAuthenticator_INTERFACE_DEFINED__ */
|
||||
|
||||
|
||||
/* Additional Prototypes for ALL interfaces */
|
||||
|
||||
unsigned long __RPC_USER HWND_UserSize( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * );
|
||||
unsigned char * __RPC_USER HWND_UserMarshal( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * );
|
||||
unsigned char * __RPC_USER HWND_UserUnmarshal(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * );
|
||||
void __RPC_USER HWND_UserFree( __RPC__in unsigned long *, __RPC__in HWND * );
|
||||
|
||||
unsigned long __RPC_USER HWND_UserSize64( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * );
|
||||
unsigned char * __RPC_USER HWND_UserMarshal64( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * );
|
||||
unsigned char * __RPC_USER HWND_UserUnmarshal64(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * );
|
||||
void __RPC_USER HWND_UserFree64( __RPC__in unsigned long *, __RPC__in HWND * );
|
||||
|
||||
/* end of Additional Prototypes */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import "oaidl.idl";
|
||||
import "objidl.idl";
|
||||
import "wtypes.idl";
|
||||
|
||||
typedef enum _WEBAUTHN_PLUGIN_REQUEST_TYPE {
|
||||
WEBAUTHN_PLUGIN_REQUEST_TYPE_CTAP2_CBOR = 0x01 // CBOR encoded CTAP2 message. Refer to the FIDO Specifications: Client to Authenticator Protocol (CTAP)
|
||||
} WEBAUTHN_PLUGIN_REQUEST_TYPE;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_OPERATION_REQUEST {
|
||||
// Handle of the top level Window of the caller
|
||||
HWND hWnd;
|
||||
|
||||
// Transaction ID
|
||||
GUID transactionId;
|
||||
|
||||
// Request Hash Signature Bytes Buffer Size
|
||||
DWORD cbRequestSignature;
|
||||
|
||||
// Request Hash Signature Bytes Buffer - Signature verified using the "pbOpSignPubKey" in PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE
|
||||
[size_is(cbRequestSignature)] byte* pbRequestSignature;
|
||||
|
||||
// Request Type - Determines the encoding of the request and response buffers
|
||||
WEBAUTHN_PLUGIN_REQUEST_TYPE requestType;
|
||||
|
||||
// Encoded Request Buffer Size
|
||||
DWORD cbEncodedRequest;
|
||||
|
||||
// Encoded Request Buffer - Encoding type is determined by the requestType
|
||||
[size_is(cbEncodedRequest)] byte* pbEncodedRequest;
|
||||
} WEBAUTHN_PLUGIN_OPERATION_REQUEST, *PWEBAUTHN_PLUGIN_OPERATION_REQUEST;
|
||||
typedef const WEBAUTHN_PLUGIN_OPERATION_REQUEST *PCWEBAUTHN_PLUGIN_OPERATION_REQUEST;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_OPERATION_RESPONSE {
|
||||
// Encoded Response Buffer Size
|
||||
DWORD cbEncodedResponse;
|
||||
|
||||
// Encoded Response Buffer - Encoding type must match the request
|
||||
[size_is(cbEncodedResponse)] byte* pbEncodedResponse;
|
||||
} WEBAUTHN_PLUGIN_OPERATION_RESPONSE, *PWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
|
||||
typedef const WEBAUTHN_PLUGIN_OPERATION_RESPONSE *PCWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST {
|
||||
// Transaction ID
|
||||
GUID transactionId;
|
||||
|
||||
// Request Hash Signature Bytes Buffer Size
|
||||
DWORD cbRequestSignature;
|
||||
|
||||
// Request Hash Signature Bytes Buffer - Signature verified using the "pbOpSignPubKey" in PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE
|
||||
[size_is(cbRequestSignature)] byte* pbRequestSignature;
|
||||
} WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST, *PWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
|
||||
typedef const WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
|
||||
|
||||
typedef enum _PLUGIN_LOCK_STATUS {
|
||||
PluginLocked = 0,
|
||||
PluginUnlocked
|
||||
} PLUGIN_LOCK_STATUS;
|
||||
|
||||
[
|
||||
object,
|
||||
uuid(d26bcf6f-b54c-43ff-9f06-d5bf148625f7),
|
||||
version(1.0),
|
||||
pointer_default(ref)
|
||||
]
|
||||
interface IPluginAuthenticator : IUnknown
|
||||
{
|
||||
HRESULT MakeCredential(
|
||||
[in] PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
|
||||
[out, retval] PWEBAUTHN_PLUGIN_OPERATION_RESPONSE response);
|
||||
|
||||
HRESULT GetAssertion(
|
||||
[in] PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
|
||||
[out, retval] PWEBAUTHN_PLUGIN_OPERATION_RESPONSE response);
|
||||
|
||||
HRESULT CancelOperation(
|
||||
[in] PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request);
|
||||
|
||||
HRESULT GetLockStatus(
|
||||
[out, retval] PLUGIN_LOCK_STATUS* lockStatus);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <winapifamily.h>
|
||||
|
||||
#pragma region Desktop Family or OneCore Family
|
||||
#if WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_APP | WINAPI_PARTITION_SYSTEM)
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#ifndef WINAPI
|
||||
#define WINAPI __stdcall
|
||||
#endif
|
||||
|
||||
#ifndef INITGUID
|
||||
#define INITGUID
|
||||
#include <guiddef.h>
|
||||
#undef INITGUID
|
||||
#else
|
||||
#include <guiddef.h>
|
||||
#endif
|
||||
|
||||
//+------------------------------------------------------------------------------------------
|
||||
// APIs.
|
||||
//-------------------------------------------------------------------------------------------
|
||||
|
||||
typedef enum _PLUGIN_AUTHENTICATOR_STATE
|
||||
{
|
||||
AuthenticatorState_Disabled = 0,
|
||||
AuthenticatorState_Enabled
|
||||
} AUTHENTICATOR_STATE;
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginGetAuthenticatorState(
|
||||
_In_ REFCLSID rclsid,
|
||||
_Out_ AUTHENTICATOR_STATE* pluginAuthenticatorState
|
||||
);
|
||||
|
||||
//
|
||||
// Plugin Authenticator API: WebAuthNAddPluginAuthenticator: Add Plugin Authenticator
|
||||
//
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS {
|
||||
// Authenticator Name
|
||||
LPCWSTR pwszAuthenticatorName;
|
||||
|
||||
// Plugin COM ClsId
|
||||
REFCLSID rclsid;
|
||||
|
||||
// Plugin RPID (Optional. Required for a nested WebAuthN call originating from a plugin)
|
||||
LPCWSTR pwszPluginRpId;
|
||||
|
||||
// Plugin Authenticator Logo for the Light themes. base64 encoded SVG 1.1 (Optional)
|
||||
LPCWSTR pwszLightThemeLogoSvg;
|
||||
|
||||
// Plugin Authenticator Logo for the Dark themes. base64 encoded SVG 1.1 (Optional)
|
||||
LPCWSTR pwszDarkThemeLogoSvg;
|
||||
|
||||
// CTAP CBOR encoded authenticatorGetInfo
|
||||
DWORD cbAuthenticatorInfo;
|
||||
_Field_size_bytes_(cbAuthenticatorInfo)
|
||||
const BYTE* pbAuthenticatorInfo;
|
||||
|
||||
// List of supported RP IDs (Relying Party IDs). Should be 0/nullptr if all RPs are supported.
|
||||
DWORD cSupportedRpIds;
|
||||
const LPCWSTR* ppwszSupportedRpIds;
|
||||
|
||||
} WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS, *PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS;
|
||||
typedef const WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS *PCWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE {
|
||||
// Plugin operation signing Public Key - Used to sign the request in PCWEBAUTHN_PLUGIN_OPERATION_REQUEST. Refer pluginauthenticator.h.
|
||||
DWORD cbOpSignPubKey;
|
||||
_Field_size_bytes_(cbOpSignPubKey)
|
||||
PBYTE pbOpSignPubKey;
|
||||
|
||||
} WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE, *PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE;
|
||||
typedef const WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE *PCWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE;
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginAddAuthenticator(
|
||||
_In_ PCWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS pPluginAddAuthenticatorOptions,
|
||||
_Outptr_result_maybenull_ PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE* ppPluginAddAuthenticatorResponse);
|
||||
|
||||
void
|
||||
WINAPI
|
||||
WebAuthNPluginFreeAddAuthenticatorResponse(
|
||||
_In_opt_ PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE pPluginAddAuthenticatorResponse);
|
||||
|
||||
//
|
||||
// Plugin Authenticator API: WebAuthNRemovePluginAuthenticator: Remove Plugin Authenticator
|
||||
//
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginRemoveAuthenticator(
|
||||
_In_ REFCLSID rclsid);
|
||||
|
||||
//
|
||||
// Plugin Authenticator API: WebAuthNPluginAuthenticatorUpdateDetails: Update Credential Metadata for Browser AutoFill Scenarios
|
||||
//
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_UPDATE_AUTHENTICATOR_DETAILS {
|
||||
// Authenticator Name (Optional)
|
||||
LPCWSTR pwszAuthenticatorName;
|
||||
|
||||
// Plugin COM ClsId
|
||||
REFCLSID rclsid;
|
||||
|
||||
// New Plugin COM ClsId (Optional)
|
||||
REFCLSID rclsidNew;
|
||||
|
||||
// Plugin Authenticator Logo for the Light themes. base64 encoded SVG 1.1 (Optional)
|
||||
LPCWSTR pwszLightThemeLogoSvg;
|
||||
|
||||
// Plugin Authenticator Logo for the Dark themes. base64 encoded SVG 1.1 (Optional)
|
||||
LPCWSTR pwszDarkThemeLogoSvg;
|
||||
|
||||
// CTAP CBOR encoded authenticatorGetInfo
|
||||
DWORD cbAuthenticatorInfo;
|
||||
_Field_size_bytes_(cbAuthenticatorInfo)
|
||||
const BYTE* pbAuthenticatorInfo;
|
||||
|
||||
// List of supported RP IDs (Relying Party IDs). Should be 0/nullptr if all RPs are supported.
|
||||
DWORD cSupportedRpIds;
|
||||
const LPCWSTR* ppwszSupportedRpIds;
|
||||
|
||||
} WEBAUTHN_PLUGIN_UPDATE_AUTHENTICATOR_DETAILS, *PWEBAUTHN_PLUGIN_UPDATE_AUTHENTICATOR_DETAILS;
|
||||
typedef const WEBAUTHN_PLUGIN_UPDATE_AUTHENTICATOR_DETAILS *PCWEBAUTHN_PLUGIN_UPDATE_AUTHENTICATOR_DETAILS;
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginUpdateAuthenticatorDetails(
|
||||
_In_ PCWEBAUTHN_PLUGIN_UPDATE_AUTHENTICATOR_DETAILS pPluginUpdateAuthenticatorDetails);
|
||||
|
||||
//
|
||||
// Plugin Authenticator API: WebAuthNPluginAuthenticatorAddCredentials: Add Credential Metadata for Browser AutoFill Scenarios
|
||||
//
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_CREDENTIAL_DETAILS {
|
||||
// Size of pbCredentialId.
|
||||
DWORD cbCredentialId;
|
||||
|
||||
// Credential Identifier bytes. This field is required.
|
||||
_Field_size_bytes_(cbCredentialId)
|
||||
const BYTE* pbCredentialId;
|
||||
|
||||
// Identifier for the RP. This field is required.
|
||||
LPCWSTR pwszRpId;
|
||||
|
||||
// Contains the friendly name of the Relying Party, such as "Acme Corporation", "Widgets Inc" or "Awesome Site".
|
||||
// This field is required.
|
||||
LPCWSTR pwszRpName;
|
||||
|
||||
// Identifier for the User. This field is required.
|
||||
DWORD cbUserId;
|
||||
|
||||
// User Identifier bytes. This field is required.
|
||||
_Field_size_bytes_(cbUserId)
|
||||
const BYTE* pbUserId;
|
||||
|
||||
// Contains a detailed name for this account, such as "john.p.smith@example.com".
|
||||
LPCWSTR pwszUserName;
|
||||
|
||||
// For User: Contains the friendly name associated with the user account such as "John P. Smith".
|
||||
LPCWSTR pwszUserDisplayName;
|
||||
|
||||
} WEBAUTHN_PLUGIN_CREDENTIAL_DETAILS, *PWEBAUTHN_PLUGIN_CREDENTIAL_DETAILS;
|
||||
typedef const WEBAUTHN_PLUGIN_CREDENTIAL_DETAILS *PCWEBAUTHN_PLUGIN_CREDENTIAL_DETAILS;
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginAuthenticatorAddCredentials(
|
||||
_In_ REFCLSID rclsid,
|
||||
_In_ DWORD cCredentialDetails,
|
||||
_In_reads_(cCredentialDetails) PCWEBAUTHN_PLUGIN_CREDENTIAL_DETAILS pCredentialDetails);
|
||||
|
||||
//
|
||||
// Plugin Authenticator API: WebAuthNPluginAuthenticatorRemoveCredentials: Remove Credential Metadata for Browser AutoFill Scenarios
|
||||
//
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginAuthenticatorRemoveCredentials(
|
||||
_In_ REFCLSID rclsid,
|
||||
_In_ DWORD cCredentialDetails,
|
||||
_In_reads_(cCredentialDetails) PCWEBAUTHN_PLUGIN_CREDENTIAL_DETAILS pCredentialDetails);
|
||||
|
||||
//
|
||||
// Plugin Authenticator API: WebAuthNPluginAuthenticatorRemoveCredentials: Remove All Credential Metadata for Browser AutoFill Scenarios
|
||||
//
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginAuthenticatorRemoveAllCredentials(
|
||||
_In_ REFCLSID rclsid);
|
||||
|
||||
//
|
||||
// Plugin Authenticator API: WebAuthNPluginAuthenticatorGetAllCredentials: Get All Credential Metadata cached for Browser AutoFill Scenarios
|
||||
//
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginAuthenticatorGetAllCredentials(
|
||||
_In_ REFCLSID rclsid,
|
||||
_Out_ DWORD* pcCredentialDetails,
|
||||
_Outptr_result_buffer_maybenull_(*pcCredentialDetails) PWEBAUTHN_PLUGIN_CREDENTIAL_DETAILS* ppCredentialDetailsArray);
|
||||
|
||||
//
|
||||
// Plugin Authenticator API: WebAuthNPluginAuthenticatorFreeCredentialDetailsList: Free Credential Metadata cached for Browser AutoFill Scenarios
|
||||
//
|
||||
|
||||
void
|
||||
WINAPI
|
||||
WebAuthNPluginAuthenticatorFreeCredentialDetailsArray(
|
||||
_In_ DWORD cCredentialDetails,
|
||||
_In_reads_(cCredentialDetails) PWEBAUTHN_PLUGIN_CREDENTIAL_DETAILS pCredentialDetailsArray);
|
||||
|
||||
//
|
||||
// Hello UV API for Plugin: WebAuthNPluginPerformUv: Perform Hello UV related operations
|
||||
//
|
||||
|
||||
typedef enum _WEBAUTHN_PLUGIN_PERFORM_UV_OPERATION_TYPE
|
||||
{
|
||||
PerformUserVerification = 1,
|
||||
GetUserVerificationCount,
|
||||
GetPublicKey
|
||||
} WEBAUTHN_PLUGIN_PERFORM_UV_OPERATION_TYPE;
|
||||
|
||||
typedef struct _WEBAUTHN_PLUGIN_USER_VERIFICATION_REQUEST {
|
||||
|
||||
// Windows handle of the top-level window displayed by the plugin and currently is in foreground as part of the ongoing webauthn operation.
|
||||
HWND hwnd;
|
||||
|
||||
// The webauthn transaction id from the WEBAUTHN_PLUGIN_OPERATION_REQUEST
|
||||
REFGUID rguidTransactionId;
|
||||
|
||||
// The username attached to the credential that is in use for this webauthn operation
|
||||
LPCWSTR pwszUsername;
|
||||
|
||||
// A text hint displayed on the windows hello prompt
|
||||
LPCWSTR pwszDisplayHint;
|
||||
} WEBAUTHN_PLUGIN_USER_VERIFICATION_REQUEST, *PWEBAUTHN_PLUGIN_USER_VERIFICATION_REQUEST;
|
||||
typedef const WEBAUTHN_PLUGIN_USER_VERIFICATION_REQUEST *PCWEBAUTHN_PLUGIN_USER_VERIFICATION_REQUEST;
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginPerformUserVerification(
|
||||
_In_ PCWEBAUTHN_PLUGIN_USER_VERIFICATION_REQUEST pPluginUserVerification,
|
||||
_Out_ DWORD* pcbResponse,
|
||||
_Outptr_result_bytebuffer_maybenull_(*pcbResponse) PBYTE* ppbResponse);
|
||||
|
||||
void
|
||||
WINAPI
|
||||
WebAuthNPluginFreeUserVerificationResponse(
|
||||
_In_opt_ PBYTE ppbResponse);
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginGetUserVerificationCount(
|
||||
_In_ REFCLSID rclsid,
|
||||
_Out_ DWORD* pdwVerificationCount);
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginGetUserVerificationPublicKey(
|
||||
_In_ REFCLSID rclsid,
|
||||
_Out_ DWORD* pcbPublicKey,
|
||||
_Outptr_result_bytebuffer_(*pcbPublicKey) PBYTE* ppbPublicKey); // Free using WebAuthNPluginFreePublicKeyResponse
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginGetOperationSigningPublicKey(
|
||||
_In_ REFCLSID rclsid,
|
||||
_Out_ DWORD* pcbOpSignPubKey,
|
||||
_Outptr_result_buffer_maybenull_(*pcbOpSignPubKey) PBYTE* ppbOpSignPubKey); // Free using WebAuthNPluginFreePublicKeyResponse
|
||||
|
||||
void WINAPI WebAuthNPluginFreePublicKeyResponse(
|
||||
_In_opt_ PBYTE pbOpSignPubKey);
|
||||
|
||||
#define WEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS_VERSION_1 1
|
||||
#define WEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS_CURRENT_VERSION WEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS_VERSION_1
|
||||
typedef struct _WEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS {
|
||||
//Version of this structure, to allow for modifications in the future.
|
||||
DWORD dwVersion;
|
||||
|
||||
// Following have following values:
|
||||
// +1 - TRUE
|
||||
// 0 - Not defined
|
||||
// -1 - FALSE
|
||||
//up: "true" | "false"
|
||||
LONG lUp;
|
||||
//uv: "true" | "false"
|
||||
LONG lUv;
|
||||
//rk: "true" | "false"
|
||||
LONG lRequireResidentKey;
|
||||
} WEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS, *PWEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS;
|
||||
typedef const WEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS *PCWEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS;
|
||||
|
||||
#define WEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY_VERSION_1 1
|
||||
#define WEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY_CURRENT_VERSION WEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY_VERSION_1
|
||||
typedef struct _WEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY {
|
||||
//Version of this structure, to allow for modifications in the future.
|
||||
DWORD dwVersion;
|
||||
|
||||
// Key type
|
||||
LONG lKty;
|
||||
|
||||
// Hash Algorithm: ES256, ES384, ES512
|
||||
LONG lAlg;
|
||||
|
||||
// Curve
|
||||
LONG lCrv;
|
||||
|
||||
//Size of "x" (X Coordinate)
|
||||
DWORD cbX;
|
||||
|
||||
//"x" (X Coordinate) data. Big Endian.
|
||||
PBYTE pbX;
|
||||
|
||||
//Size of "y" (Y Coordinate)
|
||||
DWORD cbY;
|
||||
|
||||
//"y" (Y Coordinate) data. Big Endian.
|
||||
PBYTE pbY;
|
||||
} WEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY, *PWEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY;
|
||||
typedef const WEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY *PCWEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY;
|
||||
|
||||
#define WEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION_VERSION_1 1
|
||||
#define WEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION_CURRENT_VERSION WEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION_VERSION_1
|
||||
typedef struct _WEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION {
|
||||
//Version of this structure, to allow for modifications in the future.
|
||||
DWORD dwVersion;
|
||||
|
||||
// Platform's key agreement public key
|
||||
PWEBAUTHN_CTAPCBOR_ECC_PUBLIC_KEY pKeyAgreement;
|
||||
|
||||
DWORD cbEncryptedSalt;
|
||||
PBYTE pbEncryptedSalt;
|
||||
|
||||
DWORD cbSaltAuth;
|
||||
PBYTE pbSaltAuth;
|
||||
} WEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION, *PWEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION;
|
||||
typedef const WEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION *PCWEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION;
|
||||
|
||||
#define WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST_VERSION_1 1
|
||||
#define WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST_CURRENT_VERSION WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST_VERSION_1
|
||||
typedef struct _WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST {
|
||||
//Version of this structure, to allow for modifications in the future.
|
||||
DWORD dwVersion;
|
||||
|
||||
//Input RP ID. Raw UTF8 bytes before conversion.
|
||||
//These are the bytes to be hashed in the Authenticator Data.
|
||||
DWORD cbRpId;
|
||||
PBYTE pbRpId;
|
||||
|
||||
//Client Data Hash
|
||||
DWORD cbClientDataHash;
|
||||
PBYTE pbClientDataHash;
|
||||
|
||||
//RP Information
|
||||
PCWEBAUTHN_RP_ENTITY_INFORMATION pRpInformation;
|
||||
|
||||
//User Information
|
||||
PCWEBAUTHN_USER_ENTITY_INFORMATION pUserInformation;
|
||||
|
||||
// Crypto Parameters
|
||||
WEBAUTHN_COSE_CREDENTIAL_PARAMETERS WebAuthNCredentialParameters;
|
||||
|
||||
//Credentials used for exclusion
|
||||
WEBAUTHN_CREDENTIAL_LIST CredentialList;
|
||||
|
||||
//Optional extensions to parse when performing the operation.
|
||||
DWORD cbCborExtensionsMap;
|
||||
PBYTE pbCborExtensionsMap;
|
||||
|
||||
// Authenticator Options (Optional)
|
||||
PWEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS pAuthenticatorOptions;
|
||||
|
||||
// Pin Auth (Optional)
|
||||
BOOL fEmptyPinAuth; // Zero length PinAuth is included in the request
|
||||
DWORD cbPinAuth;
|
||||
PBYTE pbPinAuth;
|
||||
|
||||
//"hmac-secret": true extension
|
||||
LONG lHmacSecretExt;
|
||||
|
||||
// "hmac-secret-mc" extension
|
||||
PWEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION pHmacSecretMcExtension;
|
||||
|
||||
//"prf" extension
|
||||
LONG lPrfExt;
|
||||
DWORD cbHmacSecretSaltValues;
|
||||
PBYTE pbHmacSecretSaltValues;
|
||||
|
||||
//"credProtect" extension. Nonzero if present
|
||||
DWORD dwCredProtect;
|
||||
|
||||
// Nonzero if present
|
||||
DWORD dwPinProtocol;
|
||||
|
||||
// Nonzero if present
|
||||
DWORD dwEnterpriseAttestation;
|
||||
|
||||
//"credBlob" extension. Nonzero if present
|
||||
DWORD cbCredBlobExt;
|
||||
PBYTE pbCredBlobExt;
|
||||
|
||||
//"largeBlobKey": true extension
|
||||
LONG lLargeBlobKeyExt;
|
||||
|
||||
//"largeBlob": extension
|
||||
DWORD dwLargeBlobSupport;
|
||||
|
||||
//"minPinLength": true extension
|
||||
LONG lMinPinLengthExt;
|
||||
|
||||
// "json" extension. Nonzero if present
|
||||
DWORD cbJsonExt;
|
||||
PBYTE pbJsonExt;
|
||||
} WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST, *PWEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST;
|
||||
typedef const WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST *PCWEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST;
|
||||
|
||||
_Success_(return == S_OK)
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNEncodeMakeCredentialResponse(
|
||||
_In_ PCWEBAUTHN_CREDENTIAL_ATTESTATION pCredentialAttestation,
|
||||
_Out_ DWORD* pcbResp,
|
||||
_Outptr_result_buffer_maybenull_(*pcbResp) BYTE** ppbResp
|
||||
);
|
||||
|
||||
_Success_(return == S_OK)
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNDecodeMakeCredentialRequest(
|
||||
_In_ DWORD cbEncoded,
|
||||
_In_reads_bytes_(cbEncoded) const BYTE* pbEncoded,
|
||||
_Outptr_ PWEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST* ppMakeCredentialRequest
|
||||
);
|
||||
|
||||
void
|
||||
WINAPI
|
||||
WebAuthNFreeDecodedMakeCredentialRequest(
|
||||
_In_opt_ PWEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST pMakeCredentialRequest
|
||||
);
|
||||
|
||||
#define WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST_VERSION_1 1
|
||||
#define WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST_CURRENT_VERSION WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST_VERSION_1
|
||||
typedef struct _WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST {
|
||||
//Version of this structure, to allow for modifications in the future.
|
||||
DWORD dwVersion;
|
||||
|
||||
//RP ID. After UTF8 to Unicode conversion,
|
||||
PCWSTR pwszRpId;
|
||||
|
||||
//Input RP ID. Raw UTF8 bytes before conversion.
|
||||
//These are the bytes to be hashed in the Authenticator Data.
|
||||
DWORD cbRpId;
|
||||
PBYTE pbRpId;
|
||||
|
||||
//Client Data Hash
|
||||
DWORD cbClientDataHash;
|
||||
PBYTE pbClientDataHash;
|
||||
|
||||
//Credentials used for inclusion
|
||||
WEBAUTHN_CREDENTIAL_LIST CredentialList;
|
||||
|
||||
//Optional extensions to parse when performing the operation.
|
||||
DWORD cbCborExtensionsMap;
|
||||
PBYTE pbCborExtensionsMap;
|
||||
|
||||
// Authenticator Options (Optional)
|
||||
PWEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS pAuthenticatorOptions;
|
||||
|
||||
// Pin Auth (Optional)
|
||||
BOOL fEmptyPinAuth; // Zero length PinAuth is included in the request
|
||||
DWORD cbPinAuth;
|
||||
PBYTE pbPinAuth;
|
||||
|
||||
// HMAC Salt Extension (Optional)
|
||||
PWEBAUTHN_CTAPCBOR_HMAC_SALT_EXTENSION pHmacSaltExtension;
|
||||
|
||||
// PRF Extension
|
||||
DWORD cbHmacSecretSaltValues;
|
||||
PBYTE pbHmacSecretSaltValues;
|
||||
|
||||
DWORD dwPinProtocol;
|
||||
|
||||
//"credBlob": true extension
|
||||
LONG lCredBlobExt;
|
||||
|
||||
//"largeBlobKey": true extension
|
||||
LONG lLargeBlobKeyExt;
|
||||
|
||||
//"largeBlob" extension
|
||||
DWORD dwCredLargeBlobOperation;
|
||||
DWORD cbCredLargeBlobCompressed;
|
||||
PBYTE pbCredLargeBlobCompressed;
|
||||
DWORD dwCredLargeBlobOriginalSize;
|
||||
|
||||
// "json" extension. Nonzero if present
|
||||
DWORD cbJsonExt;
|
||||
PBYTE pbJsonExt;
|
||||
} WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST, *PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST;
|
||||
typedef const WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST *PCWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST;
|
||||
|
||||
_Success_(return == S_OK)
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNDecodeGetAssertionRequest(
|
||||
_In_ DWORD cbEncoded,
|
||||
_In_reads_bytes_(cbEncoded) const BYTE* pbEncoded,
|
||||
_Outptr_ PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST* ppGetAssertionRequest
|
||||
);
|
||||
|
||||
void
|
||||
WINAPI
|
||||
WebAuthNFreeDecodedGetAssertionRequest(
|
||||
_In_opt_ PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST pGetAssertionRequest
|
||||
);
|
||||
|
||||
typedef struct _WEBAUTHN_CTAPCBOR_GET_ASSERTION_RESPONSE {
|
||||
// [1] credential (optional)
|
||||
// [2] authenticatorData
|
||||
// [3] signature
|
||||
WEBAUTHN_ASSERTION WebAuthNAssertion;
|
||||
|
||||
// [4] user (optional)
|
||||
PCWEBAUTHN_USER_ENTITY_INFORMATION pUserInformation;
|
||||
|
||||
// [5] numberOfCredentials (optional)
|
||||
DWORD dwNumberOfCredentials;
|
||||
|
||||
// [6] userSelected (optional)
|
||||
LONG lUserSelected;
|
||||
|
||||
// [7] largeBlobKey (optional)
|
||||
DWORD cbLargeBlobKey;
|
||||
PBYTE pbLargeBlobKey;
|
||||
|
||||
// [8] unsignedExtensionOutputs
|
||||
DWORD cbUnsignedExtensionOutputs;
|
||||
PBYTE pbUnsignedExtensionOutputs;
|
||||
} WEBAUTHN_CTAPCBOR_GET_ASSERTION_RESPONSE, *PWEBAUTHN_CTAPCBOR_GET_ASSERTION_RESPONSE;
|
||||
typedef const WEBAUTHN_CTAPCBOR_GET_ASSERTION_RESPONSE *PCWEBAUTHN_CTAPCBOR_GET_ASSERTION_RESPONSE;
|
||||
|
||||
_Success_(return == S_OK)
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNEncodeGetAssertionResponse(
|
||||
_In_ PCWEBAUTHN_CTAPCBOR_GET_ASSERTION_RESPONSE pGetAssertionResponse,
|
||||
_Out_ DWORD* pcbResp,
|
||||
_Outptr_result_buffer_maybenull_(*pcbResp) BYTE** ppbResp
|
||||
);
|
||||
|
||||
typedef void (CALLBACK* WEBAUTHN_PLUGIN_STATUS_CHANGE_CALLBACK )(void* context);
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginRegisterStatusChangeCallback(
|
||||
_In_ WEBAUTHN_PLUGIN_STATUS_CHANGE_CALLBACK callback,
|
||||
_In_ void* context,
|
||||
_In_ REFCLSID rclsid,
|
||||
_Out_ DWORD* pdwRegister
|
||||
);
|
||||
|
||||
HRESULT
|
||||
WINAPI
|
||||
WebAuthNPluginUnregisterStatusChangeCallback(
|
||||
_In_ DWORD* pdwRegister
|
||||
);
|
||||
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // Balance extern "C" above
|
||||
#endif
|
||||
|
||||
#endif // WINAPI_FAMILY_PARTITION
|
||||
#pragma endregion
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
use serde_json;
|
||||
use std::{
|
||||
alloc::{alloc, Layout},
|
||||
ptr,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use windows_core::{s, HRESULT};
|
||||
|
||||
use crate::ipc2::{
|
||||
PasskeyAssertionRequest, PasskeyAssertionResponse, Position, TimedCallback, UserVerification,
|
||||
WindowsProviderClient,
|
||||
};
|
||||
use crate::util::{debug_log, delay_load, wstr_to_string};
|
||||
use crate::webauthn::WEBAUTHN_CREDENTIAL_LIST;
|
||||
use crate::{
|
||||
com_provider::{
|
||||
parse_credential_list, WebAuthnPluginOperationRequest, WebAuthnPluginOperationResponse,
|
||||
},
|
||||
ipc2::PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
};
|
||||
|
||||
// Windows API types for WebAuthn (from webauthn.h.sample)
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST {
|
||||
pub dwVersion: u32,
|
||||
pub pwszRpId: *const u16, // PCWSTR
|
||||
pub cbRpId: u32,
|
||||
pub pbRpId: *const u8,
|
||||
pub cbClientDataHash: u32,
|
||||
pub pbClientDataHash: *const u8,
|
||||
pub CredentialList: WEBAUTHN_CREDENTIAL_LIST,
|
||||
pub cbCborExtensionsMap: u32,
|
||||
pub pbCborExtensionsMap: *const u8,
|
||||
pub pAuthenticatorOptions: *const crate::webauthn::WebAuthnCtapCborAuthenticatorOptions,
|
||||
// Add other fields as needed...
|
||||
}
|
||||
|
||||
pub type PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST = *mut WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST;
|
||||
|
||||
// Windows API function signatures for decoding get assertion requests
|
||||
type WebAuthNDecodeGetAssertionRequestFn = unsafe extern "stdcall" fn(
|
||||
cbEncoded: u32,
|
||||
pbEncoded: *const u8,
|
||||
ppGetAssertionRequest: *mut PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST,
|
||||
) -> HRESULT;
|
||||
|
||||
type WebAuthNFreeDecodedGetAssertionRequestFn =
|
||||
unsafe extern "stdcall" fn(pGetAssertionRequest: PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST);
|
||||
|
||||
// RAII wrapper for decoded get assertion request
|
||||
pub struct DecodedGetAssertionRequest {
|
||||
ptr: PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST,
|
||||
free_fn: Option<WebAuthNFreeDecodedGetAssertionRequestFn>,
|
||||
}
|
||||
|
||||
impl DecodedGetAssertionRequest {
|
||||
fn new(
|
||||
ptr: PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST,
|
||||
free_fn: Option<WebAuthNFreeDecodedGetAssertionRequestFn>,
|
||||
) -> Self {
|
||||
Self { ptr, free_fn }
|
||||
}
|
||||
|
||||
pub fn as_ref(&self) -> &WEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST {
|
||||
unsafe { &*self.ptr }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DecodedGetAssertionRequest {
|
||||
fn drop(&mut self) {
|
||||
if !self.ptr.is_null() {
|
||||
if let Some(free_fn) = self.free_fn {
|
||||
tracing::debug!("Freeing decoded get assertion request");
|
||||
unsafe {
|
||||
free_fn(self.ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to decode get assertion request using Windows API
|
||||
unsafe fn decode_get_assertion_request(
|
||||
encoded_request: &[u8],
|
||||
) -> Result<DecodedGetAssertionRequest, String> {
|
||||
tracing::debug!("Attempting to decode get assertion request using Windows API");
|
||||
|
||||
// Load the Windows WebAuthn API function
|
||||
let decode_fn: Option<WebAuthNDecodeGetAssertionRequestFn> =
|
||||
delay_load(s!("webauthn.dll"), s!("WebAuthNDecodeGetAssertionRequest"));
|
||||
|
||||
let decode_fn =
|
||||
decode_fn.ok_or("Failed to load WebAuthNDecodeGetAssertionRequest from webauthn.dll")?;
|
||||
|
||||
// Load the free function
|
||||
let free_fn: Option<WebAuthNFreeDecodedGetAssertionRequestFn> = delay_load(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNFreeDecodedGetAssertionRequest"),
|
||||
);
|
||||
|
||||
let mut pp_get_assertion_request: PWEBAUTHN_CTAPCBOR_GET_ASSERTION_REQUEST = ptr::null_mut();
|
||||
|
||||
let result = decode_fn(
|
||||
encoded_request.len() as u32,
|
||||
encoded_request.as_ptr(),
|
||||
&mut pp_get_assertion_request,
|
||||
);
|
||||
|
||||
if result.is_err() || pp_get_assertion_request.is_null() {
|
||||
return Err(format!(
|
||||
"WebAuthNDecodeGetAssertionRequest failed with HRESULT: {}",
|
||||
result.0
|
||||
));
|
||||
}
|
||||
|
||||
Ok(DecodedGetAssertionRequest::new(
|
||||
pp_get_assertion_request,
|
||||
free_fn,
|
||||
))
|
||||
}
|
||||
|
||||
/// Helper for assertion requests
|
||||
fn send_assertion_request(
|
||||
ipc_client: &WindowsProviderClient,
|
||||
request: PasskeyAssertionRequest,
|
||||
) -> Result<PasskeyAssertionResponse, String> {
|
||||
tracing::debug!(
|
||||
"Assertion request data - RP ID: {}, Client data hash: {} bytes, Allowed credentials: {:?}",
|
||||
request.rp_id,
|
||||
request.client_data_hash.len(),
|
||||
request.allowed_credentials,
|
||||
);
|
||||
|
||||
let request_json = serde_json::to_string(&request)
|
||||
.map_err(|err| format!("Failed to serialize assertion request: {err}"))?;
|
||||
tracing::debug!(?request_json, "Sending assertion request");
|
||||
let callback = Arc::new(TimedCallback::new());
|
||||
if request.allowed_credentials.len() == 1 {
|
||||
// copying this into another struct because I'm too lazy to make an enum right now.
|
||||
let request = PasskeyAssertionWithoutUserInterfaceRequest {
|
||||
rp_id: request.rp_id,
|
||||
credential_id: request.allowed_credentials[0].clone(),
|
||||
// user_name: request.user_name,
|
||||
// user_handle: request.,
|
||||
// record_identifier: todo!(),
|
||||
client_data_hash: request.client_data_hash,
|
||||
user_verification: request.user_verification,
|
||||
window_xy: request.window_xy,
|
||||
};
|
||||
ipc_client.prepare_passkey_assertion_without_user_interface(request, callback.clone());
|
||||
} else {
|
||||
ipc_client.prepare_passkey_assertion(request, callback.clone());
|
||||
}
|
||||
callback
|
||||
.wait_for_response(Duration::from_secs(30))
|
||||
.map_err(|_| "Registration request timed out".to_string())?
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Creates a WebAuthn get assertion response from Bitwarden's assertion response
|
||||
unsafe fn create_get_assertion_response(
|
||||
credential_id: Vec<u8>,
|
||||
authenticator_data: Vec<u8>,
|
||||
signature: Vec<u8>,
|
||||
user_handle: Vec<u8>,
|
||||
) -> std::result::Result<*mut WebAuthnPluginOperationResponse, HRESULT> {
|
||||
// Construct a CTAP2 response with the proper structure
|
||||
|
||||
// Create CTAP2 GetAssertion response map according to CTAP2 specification
|
||||
let mut cbor_response: Vec<(ciborium::Value, ciborium::Value)> = Vec::new();
|
||||
|
||||
// [1] credential (optional) - Always include credential descriptor
|
||||
let credential_map = vec![
|
||||
(
|
||||
ciborium::Value::Text("id".to_string()),
|
||||
ciborium::Value::Bytes(credential_id.clone()),
|
||||
),
|
||||
(
|
||||
ciborium::Value::Text("type".to_string()),
|
||||
ciborium::Value::Text("public-key".to_string()),
|
||||
),
|
||||
];
|
||||
cbor_response.push((
|
||||
ciborium::Value::Integer(1.into()),
|
||||
ciborium::Value::Map(credential_map),
|
||||
));
|
||||
|
||||
// [2] authenticatorData (required)
|
||||
cbor_response.push((
|
||||
ciborium::Value::Integer(2.into()),
|
||||
ciborium::Value::Bytes(authenticator_data),
|
||||
));
|
||||
|
||||
// [3] signature (required)
|
||||
cbor_response.push((
|
||||
ciborium::Value::Integer(3.into()),
|
||||
ciborium::Value::Bytes(signature),
|
||||
));
|
||||
|
||||
// [4] user (optional) - include if user handle is provided
|
||||
if !user_handle.is_empty() {
|
||||
let user_map = vec![(
|
||||
ciborium::Value::Text("id".to_string()),
|
||||
ciborium::Value::Bytes(user_handle),
|
||||
)];
|
||||
cbor_response.push((
|
||||
ciborium::Value::Integer(4.into()),
|
||||
ciborium::Value::Map(user_map),
|
||||
));
|
||||
}
|
||||
|
||||
// [5] numberOfCredentials (optional)
|
||||
cbor_response.push((
|
||||
ciborium::Value::Integer(5.into()),
|
||||
ciborium::Value::Integer(1.into()),
|
||||
));
|
||||
|
||||
let cbor_value = ciborium::Value::Map(cbor_response);
|
||||
|
||||
// Encode to CBOR with error handling
|
||||
let mut cbor_data = Vec::new();
|
||||
if let Err(e) = ciborium::ser::into_writer(&cbor_value, &mut cbor_data) {
|
||||
debug_log(&format!(
|
||||
"ERROR: Failed to encode CBOR assertion response: {:?}",
|
||||
e
|
||||
));
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
debug_log(&format!(
|
||||
"Formatted CBOR assertion response: {:?}",
|
||||
cbor_data
|
||||
));
|
||||
|
||||
let response_len = cbor_data.len();
|
||||
|
||||
// Allocate memory for the response data
|
||||
let layout = Layout::from_size_align(response_len, 1).map_err(|_| HRESULT(-1))?;
|
||||
let response_ptr = alloc(layout);
|
||||
if response_ptr.is_null() {
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
// Copy response data
|
||||
ptr::copy_nonoverlapping(cbor_data.as_ptr(), response_ptr, response_len);
|
||||
|
||||
// Allocate memory for the response structure
|
||||
let response_layout = Layout::new::<WebAuthnPluginOperationResponse>();
|
||||
let operation_response_ptr = alloc(response_layout) as *mut WebAuthnPluginOperationResponse;
|
||||
if operation_response_ptr.is_null() {
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
// Initialize the response
|
||||
ptr::write(
|
||||
operation_response_ptr,
|
||||
WebAuthnPluginOperationResponse {
|
||||
encoded_response_byte_count: response_len as u32,
|
||||
encoded_response_pointer: response_ptr,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(operation_response_ptr)
|
||||
}
|
||||
|
||||
/// Implementation of PluginGetAssertion moved from com_provider.rs
|
||||
pub unsafe fn plugin_get_assertion(
|
||||
ipc_client: &WindowsProviderClient,
|
||||
request: *const WebAuthnPluginOperationRequest,
|
||||
response: *mut WebAuthnPluginOperationResponse,
|
||||
) -> Result<(), HRESULT> {
|
||||
tracing::debug!("PluginGetAssertion() called");
|
||||
|
||||
// Validate input parameters
|
||||
if request.is_null() || response.is_null() {
|
||||
tracing::debug!("Invalid parameters passed to PluginGetAssertion");
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
let req = &*request;
|
||||
let transaction_id = format!("{:?}", req.transaction_id);
|
||||
let coords = req.window_coordinates().unwrap_or((400, 400));
|
||||
|
||||
debug_log(&format!(
|
||||
"Get assertion request - Transaction: {}",
|
||||
transaction_id
|
||||
));
|
||||
|
||||
if req.encoded_request_byte_count == 0 || req.encoded_request_pointer.is_null() {
|
||||
tracing::error!("No encoded request data provided");
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
let encoded_request_slice = std::slice::from_raw_parts(
|
||||
req.encoded_request_pointer,
|
||||
req.encoded_request_byte_count as usize,
|
||||
);
|
||||
|
||||
// Try to decode the request using Windows API
|
||||
let decoded_wrapper = decode_get_assertion_request(encoded_request_slice).map_err(|err| {
|
||||
tracing::debug!("Failed to decode get assertion request: {err}");
|
||||
HRESULT(-1)
|
||||
})?;
|
||||
let decoded_request = decoded_wrapper.as_ref();
|
||||
tracing::debug!("Successfully decoded get assertion request using Windows API");
|
||||
|
||||
// Extract RP information
|
||||
let rpid = if decoded_request.pwszRpId.is_null() {
|
||||
tracing::error!("RP ID is null");
|
||||
return Err(HRESULT(-1));
|
||||
} else {
|
||||
match wstr_to_string(decoded_request.pwszRpId) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to decode RP ID: {}", e);
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Extract client data hash
|
||||
let client_data_hash =
|
||||
if decoded_request.cbClientDataHash == 0 || decoded_request.pbClientDataHash.is_null() {
|
||||
tracing::error!("Client data hash is required for assertion");
|
||||
return Err(HRESULT(-1));
|
||||
} else {
|
||||
let hash_slice = std::slice::from_raw_parts(
|
||||
decoded_request.pbClientDataHash,
|
||||
decoded_request.cbClientDataHash as usize,
|
||||
);
|
||||
hash_slice.to_vec()
|
||||
};
|
||||
|
||||
// Extract user verification requirement from authenticator options
|
||||
let user_verification = if !decoded_request.pAuthenticatorOptions.is_null() {
|
||||
let auth_options = &*decoded_request.pAuthenticatorOptions;
|
||||
match auth_options.user_verification {
|
||||
1 => UserVerification::Required,
|
||||
-1 => UserVerification::Discouraged,
|
||||
0 | _ => UserVerification::Preferred, // Default or undefined
|
||||
}
|
||||
} else {
|
||||
UserVerification::Preferred // Default or undefined
|
||||
};
|
||||
|
||||
// Extract allowed credentials from credential list
|
||||
let allowed_credentials = parse_credential_list(&decoded_request.CredentialList);
|
||||
|
||||
// Create Windows assertion request
|
||||
let assertion_request = PasskeyAssertionRequest {
|
||||
rp_id: rpid.clone(),
|
||||
client_data_hash,
|
||||
allowed_credentials: allowed_credentials.clone(),
|
||||
window_xy: Position {
|
||||
x: coords.0,
|
||||
y: coords.1,
|
||||
},
|
||||
user_verification,
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
"Get assertion request - RP: {}, Allowed credentials: {:?}",
|
||||
rpid,
|
||||
allowed_credentials
|
||||
);
|
||||
|
||||
// Send assertion request
|
||||
let passkey_response =
|
||||
send_assertion_request(ipc_client, assertion_request).map_err(|err| {
|
||||
tracing::error!("Assertion request failed: {err}");
|
||||
HRESULT(-1)
|
||||
})?;
|
||||
tracing::debug!("Assertion response received: {:?}", passkey_response);
|
||||
|
||||
// Create proper WebAuthn response from passkey_response
|
||||
tracing::debug!("Creating WebAuthn get assertion response");
|
||||
|
||||
let webauthn_response = create_get_assertion_response(
|
||||
passkey_response.credential_id,
|
||||
passkey_response.authenticator_data,
|
||||
passkey_response.signature,
|
||||
passkey_response.user_handle,
|
||||
)
|
||||
.map_err(|err| {
|
||||
tracing::error!("Failed to create WebAuthn assertion response: {err}");
|
||||
HRESULT(-1)
|
||||
})?;
|
||||
tracing::debug!("Successfully created WebAuthn assertion response");
|
||||
(*response).encoded_response_byte_count = (*webauthn_response).encoded_response_byte_count;
|
||||
(*response).encoded_response_pointer = (*webauthn_response).encoded_response_pointer;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ptr::slice_from_raw_parts;
|
||||
|
||||
use super::create_get_assertion_response;
|
||||
|
||||
#[test]
|
||||
fn test_create_native_assertion_response() {
|
||||
let credential_id = vec![1, 2, 3, 4];
|
||||
let authenticator_data = vec![5, 6, 7, 8];
|
||||
let signature = vec![9, 10, 11, 12];
|
||||
let user_handle = vec![13, 14, 15, 16];
|
||||
let slice = unsafe {
|
||||
let response = *create_get_assertion_response(
|
||||
credential_id,
|
||||
authenticator_data,
|
||||
signature,
|
||||
user_handle,
|
||||
)
|
||||
.unwrap();
|
||||
&*slice_from_raw_parts(
|
||||
response.encoded_response_pointer,
|
||||
response.encoded_response_byte_count as usize,
|
||||
)
|
||||
};
|
||||
// CTAP2_OK, Map(5 elements)
|
||||
assert_eq!([0x00, 0xa5], slice[..2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::alloc;
|
||||
use std::mem::{align_of, MaybeUninit};
|
||||
use std::ptr::NonNull;
|
||||
use windows::Win32::System::Com::CoTaskMemAlloc;
|
||||
|
||||
#[repr(transparent)]
|
||||
pub struct ComBuffer(NonNull<MaybeUninit<u8>>);
|
||||
|
||||
impl ComBuffer {
|
||||
/// Returns an COM-allocated buffer of `size`.
|
||||
fn alloc(size: usize, for_slice: bool) -> Self {
|
||||
#[expect(clippy::as_conversions)]
|
||||
{
|
||||
assert!(size <= isize::MAX as usize, "requested bad object size");
|
||||
}
|
||||
|
||||
// SAFETY: Any size is valid to pass to Windows, even `0`.
|
||||
let ptr = NonNull::new(unsafe { CoTaskMemAlloc(size) }).unwrap_or_else(|| {
|
||||
// XXX: This doesn't have to be correct, just close enough for an OK OOM error.
|
||||
let layout = alloc::Layout::from_size_align(size, align_of::<u8>()).unwrap();
|
||||
alloc::handle_alloc_error(layout)
|
||||
});
|
||||
|
||||
if for_slice {
|
||||
// Ininitialize the buffer so it can later be treated as `&mut [u8]`.
|
||||
// SAFETY: The pointer is valid and we are using a valid value for a byte-wise allocation.
|
||||
unsafe { ptr.write_bytes(0, size) };
|
||||
}
|
||||
|
||||
Self(ptr.cast())
|
||||
}
|
||||
|
||||
fn into_ptr<T>(self) -> *mut T {
|
||||
self.0.cast().as_ptr()
|
||||
}
|
||||
|
||||
/// Creates a new COM-allocated structure.
|
||||
///
|
||||
/// Note that `T` must be [Copy] to avoid any possible memory leaks.
|
||||
pub fn with_object<T: Copy>(object: T) -> *mut T {
|
||||
// NB: Vendored from Rust's alloc code since we can't yet allocate `Box` with a custom allocator.
|
||||
const MIN_ALIGN: usize = if cfg!(target_pointer_width = "64") {
|
||||
16
|
||||
} else if cfg!(target_pointer_width = "32") {
|
||||
8
|
||||
} else {
|
||||
panic!("unsupported arch")
|
||||
};
|
||||
|
||||
// SAFETY: Validate that our alignment works for a normal size-based allocation for soundness.
|
||||
let layout = const {
|
||||
let layout = alloc::Layout::new::<T>();
|
||||
assert!(layout.align() <= MIN_ALIGN);
|
||||
layout
|
||||
};
|
||||
|
||||
let buffer = Self::alloc(layout.size(), false);
|
||||
// SAFETY: `ptr` is valid for writes of `T` because we correctly allocated the right sized buffer that
|
||||
// accounts for any alignment requirements.
|
||||
//
|
||||
// Additionally, we ensure the value is treated as moved by forgetting the source.
|
||||
unsafe { buffer.0.cast::<T>().write(object) };
|
||||
buffer.into_ptr()
|
||||
}
|
||||
|
||||
pub fn from_buffer<T: AsRef<[u8]>>(buffer: T) -> (*mut u8, u32) {
|
||||
let buffer = buffer.as_ref();
|
||||
let len = buffer.len();
|
||||
let com_buffer = Self::alloc(len, true);
|
||||
|
||||
// SAFETY: `ptr` points to a valid allocation that `len` matches, and we made sure
|
||||
// the bytes were initialized. Additionally, bytes have no alignment requirements.
|
||||
unsafe {
|
||||
NonNull::slice_from_raw_parts(com_buffer.0.cast::<u8>(), len)
|
||||
.as_mut()
|
||||
.copy_from_slice(buffer)
|
||||
}
|
||||
|
||||
// Safety: The Windows API structures these buffers are placed into use `u32` (`DWORD`) to
|
||||
// represent length.
|
||||
#[expect(clippy::as_conversions)]
|
||||
(com_buffer.into_ptr(), len as u32)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use windows::Win32::Foundation::{RECT, S_OK};
|
||||
use windows::Win32::System::Com::*;
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetWindowRect;
|
||||
use windows_core::{implement, interface, IInspectable, IUnknown, Interface, HRESULT};
|
||||
|
||||
use crate::assert::plugin_get_assertion;
|
||||
use crate::ipc2::WindowsProviderClient;
|
||||
use crate::make_credential::plugin_make_credential;
|
||||
use crate::util::debug_log;
|
||||
use crate::webauthn::WEBAUTHN_CREDENTIAL_LIST;
|
||||
|
||||
/// Plugin request type enum as defined in the IDL
|
||||
#[repr(u32)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum WebAuthnPluginRequestType {
|
||||
CTAP2_CBOR = 0x01,
|
||||
}
|
||||
|
||||
/// Plugin lock status enum as defined in the IDL
|
||||
#[repr(u32)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum PluginLockStatus {
|
||||
PluginLocked = 0,
|
||||
PluginUnlocked = 1,
|
||||
}
|
||||
|
||||
/// Used when creating and asserting credentials.
|
||||
/// Header File Name: _WEBAUTHN_PLUGIN_OPERATION_REQUEST
|
||||
/// Header File Usage: MakeCredential()
|
||||
/// GetAssertion()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WebAuthnPluginOperationRequest {
|
||||
pub window_handle: windows::Win32::Foundation::HWND,
|
||||
pub transaction_id: windows_core::GUID,
|
||||
pub request_signature_byte_count: u32,
|
||||
pub request_signature_pointer: *mut u8,
|
||||
pub request_type: WebAuthnPluginRequestType,
|
||||
pub encoded_request_byte_count: u32,
|
||||
pub encoded_request_pointer: *mut u8,
|
||||
}
|
||||
|
||||
impl WebAuthnPluginOperationRequest {
|
||||
pub fn window_coordinates(&self) -> Result<(i32, i32), windows::core::Error> {
|
||||
let mut window: RECT = RECT::default();
|
||||
unsafe {
|
||||
GetWindowRect(self.window_handle, &mut window)?;
|
||||
}
|
||||
// TODO: This isn't quite right, but it's closer than what we had
|
||||
let center_x = (window.right + window.left) / 2;
|
||||
let center_y = (window.bottom + window.top) / 2;
|
||||
Ok((center_x, center_y))
|
||||
}
|
||||
}
|
||||
/// Used as a response when creating and asserting credentials.
|
||||
/// Header File Name: _WEBAUTHN_PLUGIN_OPERATION_RESPONSE
|
||||
/// Header File Usage: MakeCredential()
|
||||
/// GetAssertion()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WebAuthnPluginOperationResponse {
|
||||
pub encoded_response_byte_count: u32,
|
||||
pub encoded_response_pointer: *mut u8,
|
||||
}
|
||||
|
||||
/// Used to cancel an operation.
|
||||
/// Header File Name: _WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST
|
||||
/// Header File Usage: CancelOperation()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WebAuthnPluginCancelOperationRequest {
|
||||
pub transaction_id: windows_core::GUID,
|
||||
pub request_signature_byte_count: u32,
|
||||
pub request_signature_pointer: *mut u8,
|
||||
}
|
||||
|
||||
// Stable IPluginAuthenticator interface
|
||||
#[interface("d26bcf6f-b54c-43ff-9f06-d5bf148625f7")]
|
||||
pub unsafe trait IPluginAuthenticator: windows_core::IUnknown {
|
||||
fn MakeCredential(
|
||||
&self,
|
||||
request: *const WebAuthnPluginOperationRequest,
|
||||
response: *mut WebAuthnPluginOperationResponse,
|
||||
) -> HRESULT;
|
||||
fn GetAssertion(
|
||||
&self,
|
||||
request: *const WebAuthnPluginOperationRequest,
|
||||
response: *mut WebAuthnPluginOperationResponse,
|
||||
) -> HRESULT;
|
||||
fn CancelOperation(&self, request: *const WebAuthnPluginCancelOperationRequest) -> HRESULT;
|
||||
fn GetLockStatus(&self, lock_status: *mut PluginLockStatus) -> HRESULT;
|
||||
}
|
||||
|
||||
pub unsafe fn parse_credential_list(credential_list: &WEBAUTHN_CREDENTIAL_LIST) -> Vec<Vec<u8>> {
|
||||
let mut allowed_credentials = Vec::new();
|
||||
|
||||
if credential_list.cCredentials == 0 || credential_list.ppCredentials.is_null() {
|
||||
tracing::debug!("No credentials in credential list");
|
||||
return allowed_credentials;
|
||||
}
|
||||
|
||||
debug_log(&format!(
|
||||
"Parsing {} credentials from credential list",
|
||||
credential_list.cCredentials
|
||||
));
|
||||
|
||||
// ppCredentials is an array of pointers to WEBAUTHN_CREDENTIAL_EX
|
||||
let credentials_array = std::slice::from_raw_parts(
|
||||
credential_list.ppCredentials,
|
||||
credential_list.cCredentials as usize,
|
||||
);
|
||||
|
||||
for (i, &credential_ptr) in credentials_array.iter().enumerate() {
|
||||
if credential_ptr.is_null() {
|
||||
tracing::debug!("WARNING: Credential {} is null, skipping", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
let credential = &*credential_ptr;
|
||||
|
||||
if credential.cbId == 0 || credential.pbId.is_null() {
|
||||
debug_log(&format!(
|
||||
"WARNING: Credential {} has invalid ID, skipping",
|
||||
i
|
||||
));
|
||||
continue;
|
||||
}
|
||||
// Extract credential ID bytes
|
||||
// For some reason, we're getting hex strings from Windows instead of bytes.
|
||||
let credential_id_slice =
|
||||
std::slice::from_raw_parts(credential.pbId, credential.cbId as usize);
|
||||
|
||||
debug_log(&format!(
|
||||
"Parsed credential {}: {} bytes, {:?}",
|
||||
i, credential.cbId, &credential_id_slice,
|
||||
));
|
||||
allowed_credentials.push(credential_id_slice.to_vec());
|
||||
}
|
||||
|
||||
debug_log(&format!(
|
||||
"Successfully parsed {} allowed credentials",
|
||||
allowed_credentials.len()
|
||||
));
|
||||
allowed_credentials
|
||||
}
|
||||
|
||||
#[implement(IPluginAuthenticator)]
|
||||
pub struct PluginAuthenticatorComObject {
|
||||
client: WindowsProviderClient,
|
||||
}
|
||||
|
||||
#[implement(IClassFactory)]
|
||||
pub struct Factory;
|
||||
|
||||
impl IPluginAuthenticator_Impl for PluginAuthenticatorComObject_Impl {
|
||||
unsafe fn MakeCredential(
|
||||
&self,
|
||||
request: *const WebAuthnPluginOperationRequest,
|
||||
response: *mut WebAuthnPluginOperationResponse,
|
||||
) -> HRESULT {
|
||||
tracing::debug!("MakeCredential() called");
|
||||
tracing::debug!("version2");
|
||||
// Convert to legacy format for internal processing
|
||||
if request.is_null() || response.is_null() {
|
||||
tracing::debug!("MakeCredential: Invalid request or response pointers passed");
|
||||
return HRESULT(-1);
|
||||
}
|
||||
|
||||
let response = match plugin_make_credential(&self.client, request, response) {
|
||||
Ok(()) => S_OK,
|
||||
Err(err) => err,
|
||||
};
|
||||
tracing::debug!("MakeCredential() completed");
|
||||
response
|
||||
}
|
||||
|
||||
unsafe fn GetAssertion(
|
||||
&self,
|
||||
request: *const WebAuthnPluginOperationRequest,
|
||||
response: *mut WebAuthnPluginOperationResponse,
|
||||
) -> HRESULT {
|
||||
tracing::debug!("GetAssertion() called");
|
||||
if request.is_null() || response.is_null() {
|
||||
return HRESULT(-1);
|
||||
}
|
||||
|
||||
match plugin_get_assertion(&self.client, request, response) {
|
||||
Ok(()) => S_OK,
|
||||
Err(err) => err,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn CancelOperation(
|
||||
&self,
|
||||
_request: *const WebAuthnPluginCancelOperationRequest,
|
||||
) -> HRESULT {
|
||||
tracing::debug!("CancelOperation() called");
|
||||
HRESULT(0)
|
||||
}
|
||||
|
||||
unsafe fn GetLockStatus(&self, lock_status: *mut PluginLockStatus) -> HRESULT {
|
||||
tracing::debug!("GetLockStatus() called");
|
||||
if lock_status.is_null() {
|
||||
return HRESULT(-2147024809); // E_INVALIDARG
|
||||
}
|
||||
*lock_status = PluginLockStatus::PluginUnlocked;
|
||||
HRESULT(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl IClassFactory_Impl for Factory_Impl {
|
||||
fn CreateInstance(
|
||||
&self,
|
||||
_outer: windows_core::Ref<IUnknown>,
|
||||
iid: *const windows_core::GUID,
|
||||
object: *mut *mut core::ffi::c_void,
|
||||
) -> windows_core::Result<()> {
|
||||
tracing::debug!("Creating COM server instance.");
|
||||
tracing::debug!("Trying to connect to Bitwarden IPC");
|
||||
let client = WindowsProviderClient::connect();
|
||||
tracing::debug!("Connected to Bitwarden IPC");
|
||||
let unknown: IInspectable = PluginAuthenticatorComObject { client }.into(); // TODO: IUnknown ?
|
||||
unsafe { unknown.query(iid, object).ok() }
|
||||
}
|
||||
|
||||
fn LockServer(&self, _lock: windows_core::BOOL) -> windows_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
use std::ptr;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use windows::Win32::System::Com::*;
|
||||
use windows_core::{s, ComObjectInterface, GUID, HRESULT, HSTRING, PCWSTR};
|
||||
|
||||
use crate::com_provider;
|
||||
use crate::util::delay_load;
|
||||
use crate::webauthn::*;
|
||||
use ciborium::value::Value;
|
||||
|
||||
const AUTHENTICATOR_NAME: &str = "Bitwarden Desktop";
|
||||
const CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062";
|
||||
const RPID: &str = "bitwarden.com";
|
||||
const AAGUID: &str = "d548826e-79b4-db40-a3d8-11116f7e8349";
|
||||
const LOGO_SVG: &str = r##"<svg version="1.1" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg"><path fill="#175ddc" d="M300 253.125C300 279.023 279.023 300 253.125 300H46.875C20.9766 300 0 279.023 0 253.125V46.875C0 20.9766 20.9766 0 46.875 0H253.125C279.023 0 300 20.9766 300 46.875V253.125Z"/><path fill="#fff" d="M243.105 37.6758C241.201 35.7715 238.945 34.834 236.367 34.834H63.6328C61.0254 34.834 58.7988 35.7715 56.8945 37.6758C54.9902 39.5801 54.0527 41.8359 54.0527 44.4141V159.58C54.0527 168.164 55.7227 176.689 59.0625 185.156C62.4023 193.594 66.5625 201.094 71.5137 207.656C76.4648 214.189 82.3535 220.576 89.209 226.787C96.0645 232.998 102.393 238.125 108.164 242.227C113.965 246.328 120 250.195 126.299 253.857C132.598 257.52 137.08 259.98 139.717 261.27C142.354 262.559 144.492 263.584 146.074 264.258C147.275 264.844 148.564 265.166 149.971 265.166C151.377 265.166 152.666 264.873 153.867 264.258C155.479 263.555 157.588 262.559 160.254 261.27C162.891 259.98 167.373 257.49 173.672 253.857C179.971 250.195 186.006 246.328 191.807 242.227C197.607 238.125 203.936 232.969 210.791 226.787C217.646 220.576 223.535 214.219 228.486 207.656C233.438 201.094 237.568 193.623 240.938 185.156C244.277 176.719 245.947 168.193 245.947 159.58V44.4434C245.977 41.8359 245.01 39.5801 243.105 37.6758ZM220.84 160.664C220.84 202.354 150 238.271 150 238.271V59.502H220.84C220.84 59.502 220.84 118.975 220.84 160.664Z"/></svg>"##;
|
||||
|
||||
/// Parses a UUID string (with hyphens) into bytes
|
||||
fn parse_uuid_to_bytes(uuid_str: &str) -> Result<Vec<u8>, String> {
|
||||
let uuid_clean = uuid_str.replace("-", "");
|
||||
if uuid_clean.len() != 32 {
|
||||
return Err("Invalid UUID format".to_string());
|
||||
}
|
||||
|
||||
uuid_clean
|
||||
.chars()
|
||||
.collect::<Vec<char>>()
|
||||
.chunks(2)
|
||||
.map(|chunk| {
|
||||
let hex_str: String = chunk.iter().collect();
|
||||
u8::from_str_radix(&hex_str, 16)
|
||||
.map_err(|_| format!("Invalid hex character in UUID: {}", hex_str))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Converts a CLSID string to a GUID
|
||||
pub(crate) fn parse_clsid_to_guid_str(clsid_str: &str) -> Result<GUID, String> {
|
||||
// Remove hyphens and parse as hex
|
||||
let clsid_clean = clsid_str.replace("-", "");
|
||||
if clsid_clean.len() != 32 {
|
||||
return Err("Invalid CLSID format".to_string());
|
||||
}
|
||||
|
||||
// Convert to u128 and create GUID
|
||||
let clsid_u128 = u128::from_str_radix(&clsid_clean, 16)
|
||||
.map_err(|_| "Failed to parse CLSID as hex".to_string())?;
|
||||
|
||||
Ok(GUID::from_u128(clsid_u128))
|
||||
}
|
||||
|
||||
/// Converts the CLSID constant string to a GUID
|
||||
fn parse_clsid_to_guid() -> Result<GUID, String> {
|
||||
parse_clsid_to_guid_str(CLSID)
|
||||
}
|
||||
|
||||
/// Generates CBOR-encoded authenticator info according to FIDO CTAP2 specifications
|
||||
/// See: https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#authenticatorGetInfo
|
||||
fn generate_cbor_authenticator_info() -> Result<Vec<u8>, String> {
|
||||
// Parse AAGUID from string format to bytes
|
||||
let aaguid_bytes = parse_uuid_to_bytes(AAGUID)?;
|
||||
|
||||
// Create the authenticator info map according to CTAP2 spec
|
||||
// Using Vec<(Value, Value)> because that's what ciborium::Value::Map expects
|
||||
let mut authenticator_info = Vec::new();
|
||||
|
||||
// 1: versions - Array of supported FIDO versions
|
||||
authenticator_info.push((
|
||||
Value::Integer(1.into()),
|
||||
Value::Array(vec![
|
||||
Value::Text("FIDO_2_0".to_string()),
|
||||
Value::Text("FIDO_2_1".to_string()),
|
||||
]),
|
||||
));
|
||||
|
||||
// 2: extensions - Array of supported extensions (empty for now)
|
||||
authenticator_info.push((Value::Integer(2.into()), Value::Array(vec![])));
|
||||
|
||||
// 3: aaguid - 16-byte AAGUID
|
||||
authenticator_info.push((Value::Integer(3.into()), Value::Bytes(aaguid_bytes)));
|
||||
|
||||
// 4: options - Map of supported options
|
||||
let options = vec![
|
||||
(Value::Text("rk".to_string()), Value::Bool(true)), // resident key
|
||||
(Value::Text("up".to_string()), Value::Bool(true)), // user presence
|
||||
(Value::Text("uv".to_string()), Value::Bool(true)), // user verification
|
||||
];
|
||||
authenticator_info.push((Value::Integer(4.into()), Value::Map(options)));
|
||||
|
||||
// 9: transports - Array of supported transports
|
||||
authenticator_info.push((
|
||||
Value::Integer(9.into()),
|
||||
Value::Array(vec![
|
||||
Value::Text("internal".to_string()),
|
||||
Value::Text("hybrid".to_string()),
|
||||
]),
|
||||
));
|
||||
|
||||
// 10: algorithms - Array of supported algorithms
|
||||
let algorithm = vec![
|
||||
(Value::Text("alg".to_string()), Value::Integer((-7).into())), // ES256
|
||||
(
|
||||
Value::Text("type".to_string()),
|
||||
Value::Text("public-key".to_string()),
|
||||
),
|
||||
];
|
||||
authenticator_info.push((
|
||||
Value::Integer(10.into()),
|
||||
Value::Array(vec![Value::Map(algorithm)]),
|
||||
));
|
||||
|
||||
// Encode to CBOR
|
||||
let mut buffer = Vec::new();
|
||||
ciborium::ser::into_writer(&Value::Map(authenticator_info), &mut buffer)
|
||||
.map_err(|e| format!("Failed to encode CBOR: {}", e))?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// Initializes the COM library for use on the calling thread,
|
||||
/// and registers + sets the security values.
|
||||
pub fn initialize_com_library() -> std::result::Result<(), String> {
|
||||
let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||
|
||||
if result.is_err() {
|
||||
return Err(format!(
|
||||
"Error: couldn't initialize the COM library\n{}",
|
||||
result.message()
|
||||
));
|
||||
}
|
||||
|
||||
match unsafe {
|
||||
CoInitializeSecurity(
|
||||
None,
|
||||
-1,
|
||||
None,
|
||||
None,
|
||||
RPC_C_AUTHN_LEVEL_DEFAULT,
|
||||
RPC_C_IMP_LEVEL_IMPERSONATE,
|
||||
None,
|
||||
EOAC_NONE,
|
||||
None,
|
||||
)
|
||||
} {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!(
|
||||
"Error: couldn't initialize COM security\n{}",
|
||||
e.message()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the Bitwarden Plugin Authenticator COM library with Windows.
|
||||
pub fn register_com_library() -> std::result::Result<(), String> {
|
||||
static FACTORY: windows_core::StaticComObject<com_provider::Factory> =
|
||||
com_provider::Factory.into_static();
|
||||
let clsid_guid = parse_clsid_to_guid().map_err(|e| format!("Failed to parse CLSID: {}", e))?;
|
||||
let clsid: *const GUID = &clsid_guid;
|
||||
|
||||
match unsafe {
|
||||
CoRegisterClassObject(
|
||||
clsid,
|
||||
FACTORY.as_interface_ref(),
|
||||
//FACTORY.as_interface::<pluginauthenticator::IPluginAuthenticator>(),
|
||||
CLSCTX_LOCAL_SERVER,
|
||||
REGCLS_MULTIPLEUSE,
|
||||
)
|
||||
} {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!(
|
||||
"Error: couldn't register the COM library\n{}",
|
||||
e.message()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds Bitwarden as a plugin authenticator.
|
||||
pub fn add_authenticator() -> std::result::Result<(), String> {
|
||||
let authenticator_name: HSTRING = AUTHENTICATOR_NAME.into();
|
||||
let authenticator_name_ptr = PCWSTR(authenticator_name.as_ptr()).as_ptr();
|
||||
|
||||
// Parse CLSID into GUID structure
|
||||
let clsid_guid =
|
||||
parse_clsid_to_guid().map_err(|e| format!("Failed to parse CLSID to GUID: {}", e))?;
|
||||
|
||||
let relying_party_id: HSTRING = RPID.into();
|
||||
let relying_party_id_ptr = PCWSTR(relying_party_id.as_ptr()).as_ptr();
|
||||
|
||||
// Base64-encode the SVG as required by Windows API
|
||||
let logo_b64: String = STANDARD.encode(LOGO_SVG);
|
||||
let logo_b64_buf: Vec<u16> = logo_b64.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// Generate CBOR authenticator info dynamically
|
||||
let authenticator_info_bytes = generate_cbor_authenticator_info()
|
||||
.map_err(|e| format!("Failed to generate authenticator info: {}", e))?;
|
||||
|
||||
let add_authenticator_options = WebAuthnPluginAddAuthenticatorOptions {
|
||||
authenticator_name: authenticator_name_ptr,
|
||||
rclsid: &clsid_guid, // Changed to GUID reference
|
||||
rpid: relying_party_id_ptr,
|
||||
light_theme_logo_svg: logo_b64_buf.as_ptr(),
|
||||
dark_theme_logo_svg: logo_b64_buf.as_ptr(),
|
||||
cbor_authenticator_info_byte_count: authenticator_info_bytes.len() as u32,
|
||||
cbor_authenticator_info: authenticator_info_bytes.as_ptr(), // Use as_ptr() not as_mut_ptr()
|
||||
supported_rp_ids_count: 0, // NEW field: 0 means all RPs supported
|
||||
supported_rp_ids: ptr::null(), // NEW field
|
||||
};
|
||||
|
||||
let mut add_response_ptr: *mut WebAuthnPluginAddAuthenticatorResponse = ptr::null_mut();
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginAddAuthenticatorFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginAddAuthenticator"), // Stable function name
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Some(api) => {
|
||||
let result = unsafe { api(&add_authenticator_options, &mut add_response_ptr) };
|
||||
|
||||
if result.is_err() {
|
||||
return Err(format!(
|
||||
"Error: Error response from WebAuthNPluginAddAuthenticator()\n{}",
|
||||
result.message()
|
||||
));
|
||||
}
|
||||
|
||||
// Free the response if needed
|
||||
if !add_response_ptr.is_null() {
|
||||
free_add_authenticator_response(add_response_ptr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
None => {
|
||||
Err(String::from("Error: Can't complete add_authenticator(), as the function WebAuthNPluginAddAuthenticator can't be found."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn free_add_authenticator_response(response: *mut WebAuthnPluginAddAuthenticatorResponse) {
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginFreeAddAuthenticatorResponseFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginFreeAddAuthenticatorResponse"),
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(api) = result {
|
||||
unsafe { api(response) };
|
||||
}
|
||||
}
|
||||
|
||||
type WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "cdecl" fn(
|
||||
pPluginAddAuthenticatorOptions: *const WebAuthnPluginAddAuthenticatorOptions,
|
||||
ppPluginAddAuthenticatorResponse: *mut *mut WebAuthnPluginAddAuthenticatorResponse,
|
||||
) -> HRESULT;
|
||||
|
||||
type WebAuthNPluginFreeAddAuthenticatorResponseFnDeclaration = unsafe extern "cdecl" fn(
|
||||
pPluginAddAuthenticatorResponse: *mut WebAuthnPluginAddAuthenticatorResponse,
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_cbor_authenticator_info() {
|
||||
let result = generate_cbor_authenticator_info();
|
||||
assert!(result.is_ok(), "CBOR generation should succeed");
|
||||
|
||||
let cbor_bytes = result.unwrap();
|
||||
assert!(!cbor_bytes.is_empty(), "CBOR bytes should not be empty");
|
||||
|
||||
// Verify the CBOR can be decoded back
|
||||
let decoded: Result<Value, _> = ciborium::de::from_reader(&cbor_bytes[..]);
|
||||
assert!(decoded.is_ok(), "Generated CBOR should be valid");
|
||||
|
||||
// Verify it's a map with expected keys
|
||||
if let Value::Map(map) = decoded.unwrap() {
|
||||
assert!(
|
||||
map.iter().any(|(k, _)| k == &Value::Integer(1.into())),
|
||||
"Should contain versions (key 1)"
|
||||
);
|
||||
assert!(
|
||||
map.iter().any(|(k, _)| k == &Value::Integer(2.into())),
|
||||
"Should contain extensions (key 2)"
|
||||
);
|
||||
assert!(
|
||||
map.iter().any(|(k, _)| k == &Value::Integer(3.into())),
|
||||
"Should contain aaguid (key 3)"
|
||||
);
|
||||
assert!(
|
||||
map.iter().any(|(k, _)| k == &Value::Integer(4.into())),
|
||||
"Should contain options (key 4)"
|
||||
);
|
||||
assert!(
|
||||
map.iter().any(|(k, _)| k == &Value::Integer(9.into())),
|
||||
"Should contain transports (key 9)"
|
||||
);
|
||||
assert!(
|
||||
map.iter().any(|(k, _)| k == &Value::Integer(10.into())),
|
||||
"Should contain algorithms (key 10)"
|
||||
);
|
||||
} else {
|
||||
panic!("CBOR should decode to a map");
|
||||
}
|
||||
|
||||
// Print the generated CBOR for verification
|
||||
println!("Generated CBOR hex: {}", hex::encode(&cbor_bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aaguid_parsing() {
|
||||
let result = parse_uuid_to_bytes(AAGUID);
|
||||
assert!(result.is_ok(), "AAGUID parsing should succeed");
|
||||
|
||||
let aaguid_bytes = result.unwrap();
|
||||
assert_eq!(aaguid_bytes.len(), 16, "AAGUID should be 16 bytes");
|
||||
assert_eq!(aaguid_bytes[0], 0xd5, "First byte should be 0xd5");
|
||||
assert_eq!(aaguid_bytes[1], 0x48, "Second byte should be 0x48");
|
||||
|
||||
// Verify full expected AAGUID
|
||||
let expected_hex = "d548826e79b4db40a3d811116f7e8349";
|
||||
let expected_bytes = hex::decode(expected_hex).unwrap();
|
||||
assert_eq!(
|
||||
aaguid_bytes, expected_bytes,
|
||||
"AAGUID should match expected value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_clsid_to_guid() {
|
||||
let result = parse_clsid_to_guid();
|
||||
assert!(result.is_ok(), "CLSID parsing should succeed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{BitwardenError, Callback, Position, UserVerification};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionRequest {
|
||||
pub rp_id: String,
|
||||
pub client_data_hash: Vec<u8>,
|
||||
pub user_verification: UserVerification,
|
||||
pub allowed_credentials: Vec<Vec<u8>>,
|
||||
pub window_xy: Position,
|
||||
// pub extension_input: Vec<u8>, TODO: Implement support for extensions
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
|
||||
pub rp_id: String,
|
||||
pub credential_id: Vec<u8>,
|
||||
// pub user_name: String,
|
||||
// pub user_handle: Vec<u8>,
|
||||
// pub record_identifier: Option<String>,
|
||||
pub client_data_hash: Vec<u8>,
|
||||
pub user_verification: UserVerification,
|
||||
pub window_xy: Position,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAssertionResponse {
|
||||
pub rp_id: String,
|
||||
pub user_handle: Vec<u8>,
|
||||
pub signature: Vec<u8>,
|
||||
pub client_data_hash: Vec<u8>,
|
||||
pub authenticator_data: Vec<u8>,
|
||||
pub credential_id: Vec<u8>,
|
||||
}
|
||||
|
||||
pub trait PreparePasskeyAssertionCallback: Send + Sync {
|
||||
fn on_complete(&self, credential: PasskeyAssertionResponse);
|
||||
fn on_error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
impl Callback for Arc<dyn PreparePasskeyAssertionCallback> {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
|
||||
let credential = serde_json::from_value(credential)?;
|
||||
PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, error: BitwardenError) {
|
||||
PreparePasskeyAssertionCallback::on_error(self.as_ref(), error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt::Display,
|
||||
sync::{
|
||||
atomic::AtomicU32,
|
||||
mpsc::{self, Receiver, Sender},
|
||||
Arc, Mutex, Once,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use futures::FutureExt;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{
|
||||
filter::{EnvFilter, LevelFilter},
|
||||
layer::SubscriberExt,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
mod assertion;
|
||||
mod registration;
|
||||
|
||||
pub use assertion::{
|
||||
PasskeyAssertionRequest, PasskeyAssertionResponse, PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
PreparePasskeyAssertionCallback,
|
||||
};
|
||||
pub use registration::{
|
||||
PasskeyRegistrationRequest, PasskeyRegistrationResponse, PreparePasskeyRegistrationCallback,
|
||||
};
|
||||
|
||||
use crate::util::debug_log;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UserVerification {
|
||||
Preferred,
|
||||
Required,
|
||||
Discouraged,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum BitwardenError {
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl Display for BitwardenError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Internal(msg) => write!(f, "Internal error occurred: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for BitwardenError {}
|
||||
|
||||
// TODO: These have to be named differently than the actual Uniffi traits otherwise
|
||||
// the generated code will lead to ambiguous trait implementations
|
||||
// These are only used internally, so it doesn't matter that much
|
||||
trait Callback: Send + Sync {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>;
|
||||
fn error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Store the connection status between the Windows credential provider extension
|
||||
/// and the desktop application's IPC server.
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
pub struct WindowsProviderClient {
|
||||
to_server_send: tokio::sync::mpsc::Sender<String>,
|
||||
|
||||
// We need to keep track of the callbacks so we can call them when we receive a response
|
||||
response_callbacks_counter: AtomicU32,
|
||||
#[allow(clippy::type_complexity)]
|
||||
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
|
||||
|
||||
// Flag to track connection status - atomic for thread safety without locks
|
||||
connection_status: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
/// Store native desktop status information to use for IPC communication
|
||||
/// between the application and the Windows credential provider.
|
||||
pub struct NativeStatus {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
// In our callback management, 0 is a reserved sequence number indicating that a message does not have a callback.
|
||||
const NO_CALLBACK_INDICATOR: u32 = 0;
|
||||
|
||||
impl WindowsProviderClient {
|
||||
// FIXME: Remove unwraps! They panic and terminate the whole application.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub fn connect() -> Self {
|
||||
debug_log("YO!");
|
||||
INIT.call_once(|| {
|
||||
/*
|
||||
let filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::DEBUG.into())
|
||||
.from_env_lossy();
|
||||
|
||||
let log_file_path = "C:\\temp\\bitwarden_windows_passkey_provider.log";
|
||||
debug_log(&format!("Trying to set up log file at {log_file_path}"));
|
||||
// FIXME: Remove unwrap
|
||||
let file = std::fs::File::options()
|
||||
.append(true)
|
||||
.open(log_file_path)
|
||||
.unwrap();
|
||||
let log_file = tracing_subscriber::fmt::layer().with_writer(file);
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(log_file)
|
||||
.init();
|
||||
*/
|
||||
});
|
||||
tracing::debug!("Windows COM server trying to connect to Electron IPC...");
|
||||
|
||||
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
|
||||
let client = WindowsProviderClient {
|
||||
to_server_send,
|
||||
response_callbacks_counter: AtomicU32::new(1), // Start at 1 since 0 is reserved for "no callback" scenarios
|
||||
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
|
||||
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
let path = desktop_core::ipc::path("af");
|
||||
|
||||
let queue = client.response_callbacks_queue.clone();
|
||||
let connection_status = client.connection_status.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Can't create runtime");
|
||||
|
||||
rt.spawn(
|
||||
desktop_core::ipc::client::connect(path, from_server_send, to_server_recv)
|
||||
.map(|r| r.map_err(|e| e.to_string())),
|
||||
);
|
||||
|
||||
rt.block_on(async move {
|
||||
while let Some(message) = from_server_recv.recv().await {
|
||||
match serde_json::from_str::<SerializedMessage>(&message) {
|
||||
Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
|
||||
info!("Connected to server");
|
||||
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
|
||||
info!("Disconnected from server");
|
||||
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(SerializedMessage::Message {
|
||||
sequence_number,
|
||||
value,
|
||||
}) => match queue.lock().unwrap().remove(&sequence_number) {
|
||||
Some((cb, request_start_time)) => {
|
||||
info!(
|
||||
"Time to process request: {:?}",
|
||||
request_start_time.elapsed()
|
||||
);
|
||||
match value {
|
||||
Ok(value) => {
|
||||
if let Err(e) = cb.complete(value) {
|
||||
error!(error = %e, "Error deserializing message");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Error processing message");
|
||||
cb.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
error!(sequence_number, "No callback found for sequence number")
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(error = %e, "Error deserializing message");
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
client
|
||||
}
|
||||
|
||||
pub fn send_native_status(&self, key: String, value: String) {
|
||||
let status = NativeStatus { key, value };
|
||||
self.send_message(status, None);
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_registration(
|
||||
&self,
|
||||
request: PasskeyRegistrationRequest,
|
||||
callback: Arc<dyn PreparePasskeyRegistrationCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_assertion(
|
||||
&self,
|
||||
request: PasskeyAssertionRequest,
|
||||
callback: Arc<dyn PreparePasskeyAssertionCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn prepare_passkey_assertion_without_user_interface(
|
||||
&self,
|
||||
request: PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
callback: Arc<dyn PreparePasskeyAssertionCallback>,
|
||||
) {
|
||||
self.send_message(request, Some(Box::new(callback)));
|
||||
}
|
||||
|
||||
pub fn get_connection_status(&self) -> ConnectionStatus {
|
||||
let is_connected = self
|
||||
.connection_status
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if is_connected {
|
||||
ConnectionStatus::Connected
|
||||
} else {
|
||||
ConnectionStatus::Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "command", rename_all = "camelCase")]
|
||||
enum CommandMessage {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged, rename_all = "camelCase")]
|
||||
enum SerializedMessage {
|
||||
Command(CommandMessage),
|
||||
Message {
|
||||
sequence_number: u32,
|
||||
value: Result<serde_json::Value, BitwardenError>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WindowsProviderClient {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
|
||||
let sequence_number = self
|
||||
.response_callbacks_counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
self.response_callbacks_queue
|
||||
.lock()
|
||||
.expect("response callbacks queue mutex should not be poisoned")
|
||||
.insert(sequence_number, (callback, Instant::now()));
|
||||
|
||||
sequence_number
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn send_message(
|
||||
&self,
|
||||
message: impl Serialize + DeserializeOwned,
|
||||
callback: Option<Box<dyn Callback>>,
|
||||
) {
|
||||
let sequence_number = if let Some(callback) = callback {
|
||||
self.add_callback(callback)
|
||||
} else {
|
||||
NO_CALLBACK_INDICATOR
|
||||
};
|
||||
|
||||
let message = serde_json::to_string(&SerializedMessage::Message {
|
||||
sequence_number,
|
||||
value: Ok(serde_json::to_value(message).unwrap()),
|
||||
})
|
||||
.expect("Can't serialize message");
|
||||
|
||||
if let Err(e) = self.to_server_send.blocking_send(message) {
|
||||
// Make sure we remove the callback from the queue if we can't send the message
|
||||
if sequence_number != NO_CALLBACK_INDICATOR {
|
||||
if let Some((callback, _)) = self
|
||||
.response_callbacks_queue
|
||||
.lock()
|
||||
.expect("response callbacks queue mutex should not be poisoned")
|
||||
.remove(&sequence_number)
|
||||
{
|
||||
callback.error(BitwardenError::Internal(format!(
|
||||
"Error sending message: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TimedCallback<T> {
|
||||
tx: Mutex<Option<Sender<Result<T, BitwardenError>>>>,
|
||||
rx: Mutex<Receiver<Result<T, BitwardenError>>>,
|
||||
}
|
||||
|
||||
impl<T> TimedCallback<T> {
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
Self {
|
||||
tx: Mutex::new(Some(tx)),
|
||||
rx: Mutex::new(rx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wait_for_response(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
) -> Result<Result<T, BitwardenError>, mpsc::RecvTimeoutError> {
|
||||
self.rx.lock().unwrap().recv_timeout(timeout)
|
||||
}
|
||||
|
||||
fn send(&self, response: Result<T, BitwardenError>) {
|
||||
match self.tx.lock().unwrap().take() {
|
||||
Some(tx) => {
|
||||
if let Err(_) = tx.send(response) {
|
||||
tracing::error!("Windows provider channel closed before receiving IPC response from Electron")
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("Callback channel used before response: multi-threading issue?");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreparePasskeyRegistrationCallback for TimedCallback<PasskeyRegistrationResponse> {
|
||||
fn on_complete(&self, credential: PasskeyRegistrationResponse) {
|
||||
self.send(Ok(credential));
|
||||
}
|
||||
|
||||
fn on_error(&self, error: BitwardenError) {
|
||||
self.send(Err(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl PreparePasskeyAssertionCallback for TimedCallback<PasskeyAssertionResponse> {
|
||||
fn on_complete(&self, credential: PasskeyAssertionResponse) {
|
||||
self.send(Ok(credential));
|
||||
}
|
||||
|
||||
fn on_error(&self, error: BitwardenError) {
|
||||
self.send(Err(error))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{BitwardenError, Callback, Position, UserVerification};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyRegistrationRequest {
|
||||
pub rp_id: String,
|
||||
pub user_name: String,
|
||||
pub user_handle: Vec<u8>,
|
||||
pub client_data_hash: Vec<u8>,
|
||||
pub user_verification: UserVerification,
|
||||
pub supported_algorithms: Vec<i32>,
|
||||
pub window_xy: Position,
|
||||
pub excluded_credentials: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyRegistrationResponse {
|
||||
pub rp_id: String,
|
||||
pub client_data_hash: Vec<u8>,
|
||||
pub credential_id: Vec<u8>,
|
||||
pub attestation_object: Vec<u8>,
|
||||
}
|
||||
|
||||
pub trait PreparePasskeyRegistrationCallback: Send + Sync {
|
||||
fn on_complete(&self, credential: PasskeyRegistrationResponse);
|
||||
fn on_error(&self, error: BitwardenError);
|
||||
}
|
||||
|
||||
impl Callback for Arc<dyn PreparePasskeyRegistrationCallback> {
|
||||
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
|
||||
let credential = serde_json::from_value(credential)?;
|
||||
PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, error: BitwardenError) {
|
||||
PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error);
|
||||
}
|
||||
}
|
||||
@@ -2,175 +2,35 @@
|
||||
#![allow(non_snake_case)]
|
||||
#![allow(non_camel_case_types)]
|
||||
|
||||
use std::ffi::c_uchar;
|
||||
use std::ptr;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::System::Com::*;
|
||||
use windows::Win32::System::LibraryLoader::*;
|
||||
use windows_core::*;
|
||||
|
||||
mod pluginauthenticator;
|
||||
// New modular structure
|
||||
mod assert;
|
||||
mod com_buffer;
|
||||
mod com_provider;
|
||||
mod com_registration;
|
||||
mod ipc2;
|
||||
mod make_credential;
|
||||
mod types;
|
||||
mod util;
|
||||
mod webauthn;
|
||||
|
||||
const AUTHENTICATOR_NAME: &str = "Bitwarden Desktop Authenticator";
|
||||
//const AAGUID: &str = "d548826e-79b4-db40-a3d8-11116f7e8349";
|
||||
const CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062";
|
||||
const RPID: &str = "bitwarden.com";
|
||||
// Re-export main functionality
|
||||
pub use com_registration::{add_authenticator, initialize_com_library, register_com_library};
|
||||
pub use types::UserVerificationRequirement;
|
||||
|
||||
/// Handles initialization and registration for the Bitwarden desktop app as a
|
||||
/// plugin authenticator with Windows.
|
||||
/// For now, also adds the authenticator
|
||||
pub fn register() -> std::result::Result<(), String> {
|
||||
initialize_com_library()?;
|
||||
// TODO: Can we spawn a new named thread for debugging?
|
||||
tracing::debug!("register() called...");
|
||||
|
||||
register_com_library()?;
|
||||
let r = com_registration::initialize_com_library();
|
||||
tracing::debug!("Initialized the com library: {:?}", r);
|
||||
|
||||
add_authenticator()?;
|
||||
let r = com_registration::register_com_library();
|
||||
tracing::debug!("Registered the com library: {:?}", r);
|
||||
|
||||
let r = com_registration::add_authenticator();
|
||||
tracing::debug!("Added the authenticator: {:?}", r);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initializes the COM library for use on the calling thread,
|
||||
/// and registers + sets the security values.
|
||||
fn initialize_com_library() -> std::result::Result<(), String> {
|
||||
let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||
|
||||
if result.is_err() {
|
||||
return Err(format!(
|
||||
"Error: couldn't initialize the COM library\n{}",
|
||||
result.message()
|
||||
));
|
||||
}
|
||||
|
||||
match unsafe {
|
||||
CoInitializeSecurity(
|
||||
None,
|
||||
-1,
|
||||
None,
|
||||
None,
|
||||
RPC_C_AUTHN_LEVEL_DEFAULT,
|
||||
RPC_C_IMP_LEVEL_IMPERSONATE,
|
||||
None,
|
||||
EOAC_NONE,
|
||||
None,
|
||||
)
|
||||
} {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!(
|
||||
"Error: couldn't initialize COM security\n{}",
|
||||
e.message()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the Bitwarden Plugin Authenticator COM library with Windows.
|
||||
fn register_com_library() -> std::result::Result<(), String> {
|
||||
static FACTORY: windows_core::StaticComObject<pluginauthenticator::Factory> =
|
||||
pluginauthenticator::Factory().into_static();
|
||||
let clsid: *const GUID = &GUID::from_u128(0xa98925d161f640de9327dc418fcb2ff4);
|
||||
|
||||
match unsafe {
|
||||
CoRegisterClassObject(
|
||||
clsid,
|
||||
FACTORY.as_interface_ref(),
|
||||
CLSCTX_LOCAL_SERVER,
|
||||
REGCLS_MULTIPLEUSE,
|
||||
)
|
||||
} {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!(
|
||||
"Error: couldn't register the COM library\n{}",
|
||||
e.message()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds Bitwarden as a plugin authenticator.
|
||||
// FIXME: Remove unwraps! They panic and terminate the whole application.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn add_authenticator() -> std::result::Result<(), String> {
|
||||
let authenticator_name: HSTRING = AUTHENTICATOR_NAME.into();
|
||||
let authenticator_name_ptr = PCWSTR(authenticator_name.as_ptr()).as_ptr();
|
||||
|
||||
let clsid: HSTRING = format!("{{{}}}", CLSID).into();
|
||||
let clsid_ptr = PCWSTR(clsid.as_ptr()).as_ptr();
|
||||
|
||||
let relying_party_id: HSTRING = RPID.into();
|
||||
let relying_party_id_ptr = PCWSTR(relying_party_id.as_ptr()).as_ptr();
|
||||
|
||||
// let aaguid: HSTRING = format!("{{{}}}", AAGUID).into();
|
||||
// let aaguid_ptr = PCWSTR(aaguid.as_ptr()).as_ptr();
|
||||
|
||||
// Example authenticator info blob
|
||||
let cbor_authenticator_info = "A60182684649444F5F325F30684649444F5F325F310282637072666B686D61632D7365637265740350D548826E79B4DB40A3D811116F7E834904A362726BF5627570F5627576F5098168696E7465726E616C0A81A263616C672664747970656A7075626C69632D6B6579";
|
||||
let mut authenticator_info_bytes = hex::decode(cbor_authenticator_info).unwrap();
|
||||
|
||||
let add_authenticator_options = webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions {
|
||||
authenticator_name: authenticator_name_ptr,
|
||||
com_clsid: clsid_ptr,
|
||||
rpid: relying_party_id_ptr,
|
||||
light_theme_logo: ptr::null(), // unused by Windows
|
||||
dark_theme_logo: ptr::null(), // unused by Windows
|
||||
cbor_authenticator_info_byte_count: authenticator_info_bytes.len() as u32,
|
||||
cbor_authenticator_info: authenticator_info_bytes.as_mut_ptr(),
|
||||
};
|
||||
|
||||
let plugin_signing_public_key_byte_count: u32 = 0;
|
||||
let mut plugin_signing_public_key: c_uchar = 0;
|
||||
let plugin_signing_public_key_ptr = &mut plugin_signing_public_key;
|
||||
|
||||
let mut add_response = webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse {
|
||||
plugin_operation_signing_key_byte_count: plugin_signing_public_key_byte_count,
|
||||
plugin_operation_signing_key: plugin_signing_public_key_ptr,
|
||||
};
|
||||
let mut add_response_ptr: *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse =
|
||||
&mut add_response;
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("EXPERIMENTAL_WebAuthNPluginAddAuthenticator"),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Some(api) => {
|
||||
let result = unsafe { api(&add_authenticator_options, &mut add_response_ptr) };
|
||||
|
||||
if result.is_err() {
|
||||
return Err(format!(
|
||||
"Error: Error response from EXPERIMENTAL_WebAuthNPluginAddAuthenticator()\n{}",
|
||||
result.message()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
None => {
|
||||
Err(String::from("Error: Can't complete add_authenticator(), as the function EXPERIMENTAL_WebAuthNPluginAddAuthenticator can't be found."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "cdecl" fn(
|
||||
pPluginAddAuthenticatorOptions: *const webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions,
|
||||
ppPluginAddAuthenticatorResponse: *mut *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse,
|
||||
) -> HRESULT;
|
||||
|
||||
unsafe fn delay_load<T>(library: PCSTR, function: PCSTR) -> Option<T> {
|
||||
let library = LoadLibraryExA(library, None, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
|
||||
|
||||
let Ok(library) = library else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let address = GetProcAddress(library, function);
|
||||
|
||||
if address.is_some() {
|
||||
return Some(std::mem::transmute_copy(&address));
|
||||
}
|
||||
|
||||
_ = FreeLibrary(library);
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
use serde_json;
|
||||
use std::collections::HashMap;
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::ptr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use windows_core::{s, HRESULT};
|
||||
|
||||
use crate::com_provider::{
|
||||
parse_credential_list, WebAuthnPluginOperationRequest, WebAuthnPluginOperationResponse,
|
||||
};
|
||||
use crate::ipc2::{
|
||||
PasskeyRegistrationRequest, PasskeyRegistrationResponse, Position, TimedCallback,
|
||||
UserVerification, WindowsProviderClient,
|
||||
};
|
||||
use crate::util::{debug_log, delay_load, wstr_to_string, WindowsString};
|
||||
use crate::webauthn::WEBAUTHN_CREDENTIAL_LIST;
|
||||
|
||||
// Windows API types for WebAuthn (from webauthn.h.sample)
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WEBAUTHN_RP_ENTITY_INFORMATION {
|
||||
pub dwVersion: u32,
|
||||
pub pwszId: *const u16, // PCWSTR
|
||||
pub pwszName: *const u16, // PCWSTR
|
||||
pub pwszIcon: *const u16, // PCWSTR
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WEBAUTHN_USER_ENTITY_INFORMATION {
|
||||
pub dwVersion: u32,
|
||||
pub cbId: u32, // DWORD
|
||||
pub pbId: *const u8, // PBYTE
|
||||
pub pwszName: *const u16, // PCWSTR
|
||||
pub pwszIcon: *const u16, // PCWSTR
|
||||
pub pwszDisplayName: *const u16, // PCWSTR
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WEBAUTHN_COSE_CREDENTIAL_PARAMETER {
|
||||
pub dwVersion: u32,
|
||||
pub pwszCredentialType: *const u16, // LPCWSTR
|
||||
pub lAlg: i32, // LONG - COSE algorithm identifier
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WEBAUTHN_COSE_CREDENTIAL_PARAMETERS {
|
||||
pub cCredentialParameters: u32,
|
||||
pub pCredentialParameters: *const WEBAUTHN_COSE_CREDENTIAL_PARAMETER,
|
||||
}
|
||||
|
||||
// Make Credential Request structure (from sample header)
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST {
|
||||
pub dwVersion: u32,
|
||||
pub cbRpId: u32,
|
||||
pub pbRpId: *const u8,
|
||||
pub cbClientDataHash: u32,
|
||||
pub pbClientDataHash: *const u8,
|
||||
pub pRpInformation: *const WEBAUTHN_RP_ENTITY_INFORMATION,
|
||||
pub pUserInformation: *const WEBAUTHN_USER_ENTITY_INFORMATION,
|
||||
pub WebAuthNCredentialParameters: WEBAUTHN_COSE_CREDENTIAL_PARAMETERS, // Matches C++ sample
|
||||
pub CredentialList: WEBAUTHN_CREDENTIAL_LIST,
|
||||
pub cbCborExtensionsMap: u32,
|
||||
pub pbCborExtensionsMap: *const u8,
|
||||
pub pAuthenticatorOptions: *const crate::webauthn::WebAuthnCtapCborAuthenticatorOptions,
|
||||
// Add other fields as needed...
|
||||
}
|
||||
|
||||
struct WEBAUTHN_HMAC_SECRET_SALT {
|
||||
/// Size of pbFirst.
|
||||
cbFirst: u32,
|
||||
// _Field_size_bytes_(cbFirst)
|
||||
/// Required
|
||||
pbFirst: *mut u8,
|
||||
|
||||
/// Size of pbSecond.
|
||||
cbSecond: u32,
|
||||
// _Field_size_bytes_(cbSecond)
|
||||
pbSecond: *mut u8,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
struct WEBAUTHN_EXTENSION {
|
||||
pwszExtensionIdentifier: *const u16,
|
||||
cbExtension: u32,
|
||||
pvExtension: *mut u8,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
struct WEBAUTHN_EXTENSIONS {
|
||||
cExtensions: u32,
|
||||
// _Field_size_(cExtensions)
|
||||
pExtensions: *mut WEBAUTHN_EXTENSION,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
struct WEBAUTHN_CREDENTIAL_ATTESTATION {
|
||||
/// Version of this structure, to allow for modifications in the future.
|
||||
dwVersion: u32,
|
||||
|
||||
/// Attestation format type
|
||||
pwszFormatType: *const u16, // PCWSTR
|
||||
|
||||
/// Size of cbAuthenticatorData.
|
||||
cbAuthenticatorData: u32,
|
||||
/// Authenticator data that was created for this credential.
|
||||
//_Field_size_bytes_(cbAuthenticatorData)
|
||||
pbAuthenticatorData: *mut u8,
|
||||
|
||||
/// Size of CBOR encoded attestation information
|
||||
/// 0 => encoded as CBOR null value.
|
||||
cbAttestation: u32,
|
||||
///Encoded CBOR attestation information
|
||||
// _Field_size_bytes_(cbAttestation)
|
||||
pbAttestation: *mut u8,
|
||||
|
||||
dwAttestationDecodeType: u32,
|
||||
/// Following depends on the dwAttestationDecodeType
|
||||
/// WEBAUTHN_ATTESTATION_DECODE_NONE
|
||||
/// NULL - not able to decode the CBOR attestation information
|
||||
/// WEBAUTHN_ATTESTATION_DECODE_COMMON
|
||||
/// PWEBAUTHN_COMMON_ATTESTATION;
|
||||
pvAttestationDecode: *mut u8,
|
||||
|
||||
/// The CBOR encoded Attestation Object to be returned to the RP.
|
||||
cbAttestationObject: u32,
|
||||
// _Field_size_bytes_(cbAttestationObject)
|
||||
pbAttestationObject: *mut u8,
|
||||
|
||||
/// The CredentialId bytes extracted from the Authenticator Data.
|
||||
/// Used by Edge to return to the RP.
|
||||
cbCredentialId: u32,
|
||||
// _Field_size_bytes_(cbCredentialId)
|
||||
pbCredentialId: *mut u8,
|
||||
|
||||
//
|
||||
// Following fields have been added in WEBAUTHN_CREDENTIAL_ATTESTATION_VERSION_2
|
||||
//
|
||||
/// Since VERSION 2
|
||||
Extensions: WEBAUTHN_EXTENSIONS,
|
||||
|
||||
//
|
||||
// Following fields have been added in WEBAUTHN_CREDENTIAL_ATTESTATION_VERSION_3
|
||||
//
|
||||
/// One of the WEBAUTHN_CTAP_TRANSPORT_* bits will be set corresponding to
|
||||
/// the transport that was used.
|
||||
dwUsedTransport: u32,
|
||||
|
||||
//
|
||||
// Following fields have been added in WEBAUTHN_CREDENTIAL_ATTESTATION_VERSION_4
|
||||
//
|
||||
bEpAtt: bool,
|
||||
bLargeBlobSupported: bool,
|
||||
bResidentKey: bool,
|
||||
|
||||
//
|
||||
// Following fields have been added in WEBAUTHN_CREDENTIAL_ATTESTATION_VERSION_5
|
||||
//
|
||||
bPrfEnabled: bool,
|
||||
|
||||
//
|
||||
// Following fields have been added in WEBAUTHN_CREDENTIAL_ATTESTATION_VERSION_6
|
||||
//
|
||||
cbUnsignedExtensionOutputs: u32,
|
||||
// _Field_size_bytes_(cbUnsignedExtensionOutputs)
|
||||
pbUnsignedExtensionOutputs: *mut u8,
|
||||
|
||||
//
|
||||
// Following fields have been added in WEBAUTHN_CREDENTIAL_ATTESTATION_VERSION_7
|
||||
//
|
||||
pHmacSecret: *const WEBAUTHN_HMAC_SECRET_SALT,
|
||||
|
||||
// ThirdPartyPayment Credential or not.
|
||||
bThirdPartyPayment: bool,
|
||||
|
||||
//
|
||||
// Following fields have been added in WEBAUTHN_CREDENTIAL_ATTESTATION_VERSION_8
|
||||
//
|
||||
|
||||
// Multiple WEBAUTHN_CTAP_TRANSPORT_* bits will be set corresponding to
|
||||
// the transports that are supported.
|
||||
dwTransports: u32,
|
||||
|
||||
// UTF-8 encoded JSON serialization of the client data.
|
||||
cbClientDataJSON: u32,
|
||||
// _Field_size_bytes_(cbClientDataJSON)
|
||||
pbClientDataJSON: *mut u8,
|
||||
|
||||
// UTF-8 encoded JSON serialization of the RegistrationResponse.
|
||||
cbRegistrationResponseJSON: u32,
|
||||
// _Field_size_bytes_(cbRegistrationResponseJSON)
|
||||
pbRegistrationResponseJSON: *mut u8,
|
||||
}
|
||||
|
||||
// Windows API function signatures
|
||||
type WebAuthNDecodeMakeCredentialRequestFn = unsafe extern "stdcall" fn(
|
||||
cbEncoded: u32,
|
||||
pbEncoded: *const u8,
|
||||
ppMakeCredentialRequest: *mut *mut WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST,
|
||||
) -> HRESULT;
|
||||
|
||||
type WebAuthNFreeDecodedMakeCredentialRequestFn = unsafe extern "stdcall" fn(
|
||||
pMakeCredentialRequest: *mut WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST,
|
||||
);
|
||||
|
||||
type WebAuthNEncodeMakeCredentialResponseFn = unsafe extern "stdcall" fn(
|
||||
cbEncoded: *const WEBAUTHN_CREDENTIAL_ATTESTATION,
|
||||
pbEncoded: *mut u32,
|
||||
response_bytes: *mut *mut u8,
|
||||
) -> HRESULT;
|
||||
|
||||
// RAII wrapper for decoded make credential request
|
||||
pub struct DecodedMakeCredentialRequest {
|
||||
ptr: *const WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST,
|
||||
free_fn: Option<WebAuthNFreeDecodedMakeCredentialRequestFn>,
|
||||
}
|
||||
|
||||
impl DecodedMakeCredentialRequest {
|
||||
fn new(
|
||||
ptr: *const WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST,
|
||||
free_fn: Option<WebAuthNFreeDecodedMakeCredentialRequestFn>,
|
||||
) -> Self {
|
||||
Self { ptr, free_fn }
|
||||
}
|
||||
|
||||
pub fn as_ref(&self) -> &WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST {
|
||||
unsafe { &*self.ptr }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DecodedMakeCredentialRequest {
|
||||
fn drop(&mut self) {
|
||||
if !self.ptr.is_null() {
|
||||
if let Some(free_fn) = self.free_fn {
|
||||
tracing::debug!("Freeing decoded make credential request");
|
||||
unsafe {
|
||||
free_fn(self.ptr as *mut WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to decode make credential request using Windows API
|
||||
unsafe fn decode_make_credential_request(
|
||||
encoded_request: &[u8],
|
||||
) -> Result<DecodedMakeCredentialRequest, String> {
|
||||
tracing::debug!("Attempting to decode make credential request using Windows API");
|
||||
|
||||
// Try to load the Windows API decode function
|
||||
let decode_fn = match delay_load::<WebAuthNDecodeMakeCredentialRequestFn>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNDecodeMakeCredentialRequest"),
|
||||
) {
|
||||
Some(func) => func,
|
||||
None => {
|
||||
return Err(
|
||||
"Failed to load WebAuthNDecodeMakeCredentialRequest from webauthn.dll".to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Try to load the free function (optional, might not be available in all versions)
|
||||
let free_fn = delay_load::<WebAuthNFreeDecodedMakeCredentialRequestFn>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNFreeDecodedMakeCredentialRequest"),
|
||||
);
|
||||
|
||||
// Prepare parameters for the API call
|
||||
let cb_encoded = encoded_request.len() as u32;
|
||||
let pb_encoded = encoded_request.as_ptr();
|
||||
let mut make_credential_request: *mut WEBAUTHN_CTAPCBOR_MAKE_CREDENTIAL_REQUEST =
|
||||
std::ptr::null_mut();
|
||||
|
||||
// Call the Windows API function
|
||||
let result = decode_fn(cb_encoded, pb_encoded, &mut make_credential_request);
|
||||
|
||||
// Check if the call succeeded (following C++ THROW_IF_FAILED pattern)
|
||||
if result.is_err() {
|
||||
debug_log(&format!(
|
||||
"ERROR: WebAuthNDecodeMakeCredentialRequest failed with HRESULT: 0x{:08x}",
|
||||
result.0
|
||||
));
|
||||
return Err(format!(
|
||||
"Windows API call failed with HRESULT: 0x{:08x}",
|
||||
result.0
|
||||
));
|
||||
}
|
||||
|
||||
if make_credential_request.is_null() {
|
||||
tracing::error!("Windows API succeeded but returned null pointer");
|
||||
return Err("Windows API returned null pointer".to_string());
|
||||
}
|
||||
|
||||
Ok(DecodedMakeCredentialRequest::new(
|
||||
make_credential_request,
|
||||
free_fn,
|
||||
))
|
||||
}
|
||||
|
||||
/// Helper for registration requests
|
||||
fn send_registration_request(
|
||||
ipc_client: &WindowsProviderClient,
|
||||
request: PasskeyRegistrationRequest,
|
||||
) -> Result<PasskeyRegistrationResponse, String> {
|
||||
debug_log(&format!("Registration request data - RP ID: {}, User ID: {} bytes, User name: {}, Client data hash: {} bytes, Algorithms: {:?}, Excluded credentials: {}",
|
||||
request.rp_id, request.user_handle.len(), request.user_name, request.client_data_hash.len(), request.supported_algorithms, request.excluded_credentials.len()));
|
||||
|
||||
let request_json = serde_json::to_string(&request)
|
||||
.map_err(|err| format!("Failed to serialize registration request: {err}"))?;
|
||||
tracing::debug!("Sending registration request: {}", request_json);
|
||||
let callback = Arc::new(TimedCallback::new());
|
||||
ipc_client.prepare_passkey_registration(request, callback.clone());
|
||||
let response = callback
|
||||
.wait_for_response(Duration::from_secs(30))
|
||||
.map_err(|_| "Registration request timed out".to_string())?
|
||||
.map_err(|err| err.to_string());
|
||||
if response.is_ok() {
|
||||
tracing::debug!("Requesting credential sync after registering a new credential.");
|
||||
ipc_client.send_native_status("request-sync".to_string(), "".to_string());
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Creates a CTAP make credential response from Bitwarden's WebAuthn registration response
|
||||
unsafe fn create_make_credential_response(
|
||||
attestation_object: Vec<u8>,
|
||||
) -> std::result::Result<Vec<u8>, HRESULT> {
|
||||
use ciborium::Value;
|
||||
// Use the attestation object directly as the encoded response
|
||||
let att_obj_items = ciborium::from_reader::<Value, _>(&attestation_object[..])
|
||||
.map_err(|_| HRESULT(-1))?
|
||||
.into_map()
|
||||
.map_err(|_| HRESULT(-1))?;
|
||||
|
||||
let webauthn_att_obj: HashMap<&str, &Value> = att_obj_items
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_text().unwrap(), v))
|
||||
.collect();
|
||||
|
||||
/*
|
||||
let ctap_attestation_response = ciborium::Value::Map(vec![
|
||||
(Value::Integer(1.into()), webauthn_att_obj["fmt"].clone()),
|
||||
(
|
||||
Value::Integer(2.into()),
|
||||
webauthn_att_obj["authData"].clone(),
|
||||
),
|
||||
(
|
||||
Value::Integer(3.into()),
|
||||
webauthn_att_obj["attStmt"].clone(),
|
||||
),
|
||||
]);
|
||||
|
||||
// Write data into CBOR
|
||||
// let mut response = Vec::new();
|
||||
// ciborium::into_writer(&ctap_attestation_response, &mut response).map_err(|_| HRESULT(-1))?;
|
||||
*/
|
||||
|
||||
let webauthn_encode_make_credential_response =
|
||||
delay_load::<WebAuthNEncodeMakeCredentialResponseFn>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNEncodeMakeCredentialResponse"),
|
||||
)
|
||||
.unwrap();
|
||||
let att_fmt = webauthn_att_obj
|
||||
.get("fmt")
|
||||
.ok_or(HRESULT(-1))?
|
||||
.as_text()
|
||||
.ok_or(HRESULT(-1))?
|
||||
.to_utf16();
|
||||
let authenticator_data = webauthn_att_obj
|
||||
.get("authData")
|
||||
.ok_or(HRESULT(-1))?
|
||||
.as_bytes()
|
||||
.ok_or(HRESULT(-1))?;
|
||||
let attestation = WEBAUTHN_CREDENTIAL_ATTESTATION {
|
||||
dwVersion: 8,
|
||||
pwszFormatType: att_fmt.as_ptr(),
|
||||
cbAuthenticatorData: authenticator_data.len() as u32,
|
||||
pbAuthenticatorData: authenticator_data.as_ptr() as *mut u8,
|
||||
cbAttestation: 0,
|
||||
pbAttestation: ptr::null_mut(),
|
||||
dwAttestationDecodeType: 0,
|
||||
pvAttestationDecode: ptr::null_mut(),
|
||||
cbAttestationObject: 0,
|
||||
pbAttestationObject: ptr::null_mut(),
|
||||
cbCredentialId: 0,
|
||||
pbCredentialId: ptr::null_mut(),
|
||||
Extensions: WEBAUTHN_EXTENSIONS {
|
||||
cExtensions: 0,
|
||||
pExtensions: ptr::null_mut(),
|
||||
},
|
||||
dwUsedTransport: 0x00000010, // INTERNAL
|
||||
bEpAtt: false,
|
||||
bLargeBlobSupported: false,
|
||||
bResidentKey: false,
|
||||
bPrfEnabled: false,
|
||||
cbUnsignedExtensionOutputs: 0,
|
||||
pbUnsignedExtensionOutputs: ptr::null_mut(),
|
||||
pHmacSecret: ptr::null_mut(),
|
||||
bThirdPartyPayment: false,
|
||||
dwTransports: 0x00000030, // INTERNAL, HYBRID
|
||||
cbClientDataJSON: 0,
|
||||
pbClientDataJSON: ptr::null_mut(),
|
||||
cbRegistrationResponseJSON: 0,
|
||||
pbRegistrationResponseJSON: ptr::null_mut(),
|
||||
};
|
||||
let mut response_len = 0;
|
||||
let mut response_ptr = ptr::null_mut();
|
||||
let result = webauthn_encode_make_credential_response(
|
||||
&attestation,
|
||||
&mut response_len,
|
||||
&mut response_ptr,
|
||||
);
|
||||
if result.is_err() {
|
||||
return Err(result);
|
||||
}
|
||||
let response = Vec::from_raw_parts(response_ptr, response_len as usize, response_len as usize);
|
||||
|
||||
Ok(response)
|
||||
/*
|
||||
// Allocate memory for the response data
|
||||
let layout = Layout::from_size_align(response_len as usize, 1).map_err(|_| HRESULT(-1))?;
|
||||
let response_ptr = alloc(layout);
|
||||
if response_ptr.is_null() {
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
// Copy response data
|
||||
ptr::copy_nonoverlapping(response, response_ptr, response.len());
|
||||
|
||||
// Allocate memory for the response structure
|
||||
let response_layout = Layout::new::<WebAuthnPluginOperationResponse>();
|
||||
let operation_response_ptr = alloc(response_layout) as *mut WebAuthnPluginOperationResponse;
|
||||
if operation_response_ptr.is_null() {
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
// Initialize the response
|
||||
ptr::write(
|
||||
operation_response_ptr,
|
||||
WebAuthnPluginOperationResponse {
|
||||
encoded_response_byte_count: response.len() as u32,
|
||||
encoded_response_pointer: response_ptr,
|
||||
},
|
||||
);
|
||||
tracing::debug!("CTAP-encoded attestation object: {response:?}");
|
||||
Ok(operation_response_ptr)
|
||||
*/
|
||||
}
|
||||
|
||||
/// Implementation of PluginMakeCredential moved from com_provider.rs
|
||||
pub unsafe fn plugin_make_credential(
|
||||
ipc_client: &WindowsProviderClient,
|
||||
request: *const WebAuthnPluginOperationRequest,
|
||||
response: *mut WebAuthnPluginOperationResponse,
|
||||
) -> Result<(), HRESULT> {
|
||||
tracing::debug!("=== PluginMakeCredential() called ===");
|
||||
|
||||
if request.is_null() {
|
||||
tracing::error!("NULL request pointer");
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
if response.is_null() {
|
||||
tracing::error!("NULL response pointer");
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
let req = &*request;
|
||||
let transaction_id = format!("{:?}", req.transaction_id);
|
||||
|
||||
let coords = req.window_coordinates().unwrap_or((400, 400));
|
||||
|
||||
if req.encoded_request_byte_count == 0 || req.encoded_request_pointer.is_null() {
|
||||
tracing::error!("No encoded request data provided");
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
let encoded_request_slice = std::slice::from_raw_parts(
|
||||
req.encoded_request_pointer,
|
||||
req.encoded_request_byte_count as usize,
|
||||
);
|
||||
|
||||
debug_log(&format!(
|
||||
"Encoded request: {} bytes",
|
||||
encoded_request_slice.len()
|
||||
));
|
||||
|
||||
// Try to decode the request using Windows API
|
||||
let decoded_wrapper = decode_make_credential_request(encoded_request_slice).map_err(|err| {
|
||||
debug_log(&format!(
|
||||
"ERROR: Failed to decode make credential request: {err}"
|
||||
));
|
||||
HRESULT(-1)
|
||||
})?;
|
||||
let decoded_request = decoded_wrapper.as_ref();
|
||||
tracing::debug!("Successfully decoded make credential request using Windows API");
|
||||
|
||||
// Extract RP information
|
||||
if decoded_request.pRpInformation.is_null() {
|
||||
tracing::error!("RP information is null");
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
let rp_info = &*decoded_request.pRpInformation;
|
||||
|
||||
let rpid = if rp_info.pwszId.is_null() {
|
||||
tracing::error!("RP ID is null");
|
||||
return Err(HRESULT(-1));
|
||||
} else {
|
||||
match wstr_to_string(rp_info.pwszId) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to decode RP ID: {}", e);
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// let rp_name = if rp_info.pwszName.is_null() {
|
||||
// String::new()
|
||||
// } else {
|
||||
// wstr_to_string(rp_info.pwszName).unwrap_or_default()
|
||||
// };
|
||||
|
||||
// Extract user information
|
||||
if decoded_request.pUserInformation.is_null() {
|
||||
tracing::error!("User information is null");
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
|
||||
let user = &*decoded_request.pUserInformation;
|
||||
|
||||
let user_id = if user.pbId.is_null() || user.cbId == 0 {
|
||||
tracing::error!("User ID is required for registration");
|
||||
return Err(HRESULT(-1));
|
||||
} else {
|
||||
let id_slice = std::slice::from_raw_parts(user.pbId, user.cbId as usize);
|
||||
id_slice.to_vec()
|
||||
};
|
||||
|
||||
let user_name = if user.pwszName.is_null() {
|
||||
tracing::error!("User name is required for registration");
|
||||
return Err(HRESULT(-1));
|
||||
} else {
|
||||
match wstr_to_string(user.pwszName) {
|
||||
Ok(name) => name,
|
||||
Err(_) => {
|
||||
tracing::error!("Failed to decode user name");
|
||||
return Err(HRESULT(-1));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let user_display_name = if user.pwszDisplayName.is_null() {
|
||||
None
|
||||
} else {
|
||||
wstr_to_string(user.pwszDisplayName).ok()
|
||||
};
|
||||
|
||||
let user_info = (user_id, user_name, user_display_name);
|
||||
|
||||
// Extract client data hash
|
||||
let client_data_hash =
|
||||
if decoded_request.cbClientDataHash == 0 || decoded_request.pbClientDataHash.is_null() {
|
||||
tracing::error!("Client data hash is required for registration");
|
||||
return Err(HRESULT(-1));
|
||||
} else {
|
||||
let hash_slice = std::slice::from_raw_parts(
|
||||
decoded_request.pbClientDataHash,
|
||||
decoded_request.cbClientDataHash as usize,
|
||||
);
|
||||
hash_slice.to_vec()
|
||||
};
|
||||
|
||||
// Extract supported algorithms
|
||||
let supported_algorithms = if decoded_request
|
||||
.WebAuthNCredentialParameters
|
||||
.cCredentialParameters
|
||||
> 0
|
||||
&& !decoded_request
|
||||
.WebAuthNCredentialParameters
|
||||
.pCredentialParameters
|
||||
.is_null()
|
||||
{
|
||||
let params_count = decoded_request
|
||||
.WebAuthNCredentialParameters
|
||||
.cCredentialParameters as usize;
|
||||
let params_ptr = decoded_request
|
||||
.WebAuthNCredentialParameters
|
||||
.pCredentialParameters;
|
||||
|
||||
(0..params_count)
|
||||
.map(|i| unsafe { &*params_ptr.add(i) }.lAlg)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Extract user verification requirement from authenticator options
|
||||
let user_verification = if !decoded_request.pAuthenticatorOptions.is_null() {
|
||||
let auth_options = &*decoded_request.pAuthenticatorOptions;
|
||||
match auth_options.user_verification {
|
||||
1 => UserVerification::Required,
|
||||
-1 => UserVerification::Discouraged,
|
||||
0 | _ => UserVerification::Preferred, // Default or undefined
|
||||
}
|
||||
} else {
|
||||
UserVerification::Preferred // Default or undefined
|
||||
};
|
||||
|
||||
// Extract excluded credentials from credential list
|
||||
let excluded_credentials = parse_credential_list(&decoded_request.CredentialList);
|
||||
if !excluded_credentials.is_empty() {
|
||||
debug_log(&format!(
|
||||
"Found {} excluded credentials for make credential",
|
||||
excluded_credentials.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Create Windows registration request
|
||||
let registration_request = PasskeyRegistrationRequest {
|
||||
rp_id: rpid.clone(),
|
||||
user_handle: user_info.0,
|
||||
user_name: user_info.1,
|
||||
// user_display_name: user_info.2,
|
||||
client_data_hash,
|
||||
excluded_credentials,
|
||||
user_verification: user_verification,
|
||||
supported_algorithms,
|
||||
window_xy: Position {
|
||||
x: coords.0,
|
||||
y: coords.1,
|
||||
},
|
||||
};
|
||||
|
||||
debug_log(&format!(
|
||||
"Make credential request - RP: {}, User: {}",
|
||||
rpid, registration_request.user_name
|
||||
));
|
||||
|
||||
// Send registration request
|
||||
let passkey_response =
|
||||
send_registration_request(ipc_client, registration_request).map_err(|err| {
|
||||
tracing::error!("Registration request failed: {err}");
|
||||
HRESULT(-1)
|
||||
})?;
|
||||
debug_log(&format!(
|
||||
"Registration response received: {:?}",
|
||||
passkey_response
|
||||
));
|
||||
|
||||
// Create proper WebAuthn response from passkey_response
|
||||
tracing::debug!("Creating WebAuthn make credential response");
|
||||
let mut webauthn_response =
|
||||
create_make_credential_response(passkey_response.attestation_object).map_err(|err| {
|
||||
tracing::error!("Failed to create WebAuthn response: {err}");
|
||||
HRESULT(-1)
|
||||
})?;
|
||||
debug_log(&format!(
|
||||
"Successfully created WebAuthn response: {webauthn_response:?}"
|
||||
));
|
||||
(*response).encoded_response_byte_count = webauthn_response.len() as u32;
|
||||
(*response).encoded_response_pointer = webauthn_response.as_mut_ptr();
|
||||
tracing::debug!("Set pointer, returning HRESULT(0)");
|
||||
_ = ManuallyDrop::new(webauthn_response);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ptr;
|
||||
|
||||
use windows_core::s;
|
||||
|
||||
use crate::{
|
||||
make_credential::{
|
||||
create_make_credential_response, WebAuthNEncodeMakeCredentialResponseFn,
|
||||
WEBAUTHN_CREDENTIAL_ATTESTATION, WEBAUTHN_EXTENSIONS,
|
||||
},
|
||||
util::{delay_load, WindowsString},
|
||||
};
|
||||
#[test]
|
||||
fn test_encode_make_credential_custom() {
|
||||
let webauthn_att_obj = vec![
|
||||
163, 99, 102, 109, 116, 100, 110, 111, 110, 101, 103, 97, 116, 116, 83, 116, 109, 116,
|
||||
160, 104, 97, 117, 116, 104, 68, 97, 116, 97, 68, 1, 2, 3, 4,
|
||||
];
|
||||
/*
|
||||
148, 116, 166, 234, 146, 19, 201,
|
||||
156, 47, 116, 178, 36, 146, 179, 32, 207, 64, 38, 42, 148, 193, 169, 80, 160, 57, 127,
|
||||
41, 37, 11, 96, 132, 30, 240, 93, 0, 0, 0, 0, 213, 72, 130, 110, 121, 180, 219, 64,
|
||||
163, 216, 17, 17, 111, 126, 131, 73, 0, 16, 41, 58, 58, 242, 229, 31, 75, 22, 168, 253,
|
||||
151, 122, 177, 155, 237, 89, 165, 1, 2, 3, 38, 32, 1, 33, 88, 32, 154, 18, 243, 88, 48,
|
||||
112, 84, 3, 82, 219, 172, 210, 76, 151, 246, 101, 189, 86, 147, 114, 248, 43, 231, 192,
|
||||
202, 190, 92, 37, 216, 45, 202, 250, 34, 88, 32, 28, 36, 149, 44, 106, 229, 243, 164,
|
||||
190, 234, 102, 125, 168, 224, 155, 182, 190, 178, 218, 158, 98, 11, 57, 187, 41, 10,
|
||||
218, 58, 80, 124, 254, 119,
|
||||
];
|
||||
*/
|
||||
let ctap_att_obj = unsafe { create_make_credential_response(webauthn_att_obj).unwrap() };
|
||||
println!("{ctap_att_obj:?}");
|
||||
let expected = vec![163, 1, 100, 110, 111, 110, 101, 2, 68, 1, 2, 3, 4, 3, 160];
|
||||
assert_eq!(expected, ctap_att_obj);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_make_credential() {
|
||||
let response = unsafe {
|
||||
let webauthn_encode_make_credential_response =
|
||||
delay_load::<WebAuthNEncodeMakeCredentialResponseFn>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNEncodeMakeCredentialResponse"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut authenticator_data = vec![1, 2, 3, 4];
|
||||
let att_fmt = "none".to_utf16();
|
||||
let attestation = WEBAUTHN_CREDENTIAL_ATTESTATION {
|
||||
dwVersion: 8,
|
||||
pwszFormatType: att_fmt.as_ptr(),
|
||||
cbAuthenticatorData: authenticator_data.len() as u32,
|
||||
pbAuthenticatorData: authenticator_data.as_mut_ptr(),
|
||||
cbAttestation: 0,
|
||||
pbAttestation: ptr::null_mut(),
|
||||
dwAttestationDecodeType: 0,
|
||||
pvAttestationDecode: ptr::null_mut(),
|
||||
cbAttestationObject: 0,
|
||||
pbAttestationObject: ptr::null_mut(),
|
||||
cbCredentialId: 0,
|
||||
pbCredentialId: ptr::null_mut(),
|
||||
Extensions: WEBAUTHN_EXTENSIONS {
|
||||
cExtensions: 0,
|
||||
pExtensions: ptr::null_mut(),
|
||||
},
|
||||
dwUsedTransport: 0x00000010, // INTERNAL
|
||||
bEpAtt: false,
|
||||
bLargeBlobSupported: false,
|
||||
bResidentKey: false,
|
||||
bPrfEnabled: false,
|
||||
cbUnsignedExtensionOutputs: 0,
|
||||
pbUnsignedExtensionOutputs: ptr::null_mut(),
|
||||
pHmacSecret: ptr::null_mut(),
|
||||
bThirdPartyPayment: false,
|
||||
dwTransports: 0x00000030, // INTERNAL, HYBRID
|
||||
cbClientDataJSON: 0,
|
||||
pbClientDataJSON: ptr::null_mut(),
|
||||
cbRegistrationResponseJSON: 0,
|
||||
pbRegistrationResponseJSON: ptr::null_mut(),
|
||||
};
|
||||
let mut len = 0;
|
||||
let mut response_ptr = ptr::null_mut();
|
||||
let result =
|
||||
webauthn_encode_make_credential_response(&attestation, &mut len, &mut response_ptr);
|
||||
assert!(result.is_ok());
|
||||
Vec::from_raw_parts(response_ptr, len as usize, len as usize)
|
||||
};
|
||||
println!("{response:?}");
|
||||
assert_eq!(165, response[0]);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
This file exposes the functions and types defined here: https://github.com/microsoft/webauthn/blob/master/experimental/pluginauthenticator.h
|
||||
*/
|
||||
|
||||
use windows::Win32::System::Com::*;
|
||||
use windows_core::*;
|
||||
|
||||
/// Used when creating and asserting credentials.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST
|
||||
/// Header File Usage: EXPERIMENTAL_PluginMakeCredential()
|
||||
/// EXPERIMENTAL_PluginGetAssertion()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginOperationRequest {
|
||||
pub window_handle: windows::Win32::Foundation::HWND,
|
||||
pub transaction_id: windows_core::GUID,
|
||||
pub request_signature_byte_count: u32,
|
||||
pub request_signature_pointer: *mut u8,
|
||||
pub encoded_request_byte_count: u32,
|
||||
pub encoded_request_pointer: *mut u8,
|
||||
}
|
||||
|
||||
/// Used as a response when creating and asserting credentials.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE
|
||||
/// Header File Usage: EXPERIMENTAL_PluginMakeCredential()
|
||||
/// EXPERIMENTAL_PluginGetAssertion()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginOperationResponse {
|
||||
pub encoded_response_byte_count: u32,
|
||||
pub encoded_response_pointer: *mut u8,
|
||||
}
|
||||
|
||||
/// Used to cancel an operation.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST
|
||||
/// Header File Usage: EXPERIMENTAL_PluginCancelOperation()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginCancelOperationRequest {
|
||||
pub transaction_id: windows_core::GUID,
|
||||
pub request_signature_byte_count: u32,
|
||||
pub request_signature_pointer: *mut u8,
|
||||
}
|
||||
|
||||
#[interface("e6466e9a-b2f3-47c5-b88d-89bc14a8d998")]
|
||||
pub unsafe trait EXPERIMENTAL_IPluginAuthenticator: IUnknown {
|
||||
fn EXPERIMENTAL_PluginMakeCredential(
|
||||
&self,
|
||||
request: *const ExperimentalWebAuthnPluginOperationRequest,
|
||||
response: *mut ExperimentalWebAuthnPluginOperationResponse,
|
||||
) -> HRESULT;
|
||||
fn EXPERIMENTAL_PluginGetAssertion(
|
||||
&self,
|
||||
request: *const ExperimentalWebAuthnPluginOperationRequest,
|
||||
response: *mut ExperimentalWebAuthnPluginOperationResponse,
|
||||
) -> HRESULT;
|
||||
fn EXPERIMENTAL_PluginCancelOperation(
|
||||
&self,
|
||||
request: *const ExperimentalWebAuthnPluginCancelOperationRequest,
|
||||
) -> HRESULT;
|
||||
}
|
||||
|
||||
#[implement(EXPERIMENTAL_IPluginAuthenticator)]
|
||||
pub struct PluginAuthenticatorComObject;
|
||||
|
||||
#[implement(IClassFactory)]
|
||||
pub struct Factory();
|
||||
|
||||
impl EXPERIMENTAL_IPluginAuthenticator_Impl for PluginAuthenticatorComObject_Impl {
|
||||
unsafe fn EXPERIMENTAL_PluginMakeCredential(
|
||||
&self,
|
||||
_request: *const ExperimentalWebAuthnPluginOperationRequest,
|
||||
_response: *mut ExperimentalWebAuthnPluginOperationResponse,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
|
||||
unsafe fn EXPERIMENTAL_PluginGetAssertion(
|
||||
&self,
|
||||
_request: *const ExperimentalWebAuthnPluginOperationRequest,
|
||||
_response: *mut ExperimentalWebAuthnPluginOperationResponse,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
|
||||
unsafe fn EXPERIMENTAL_PluginCancelOperation(
|
||||
&self,
|
||||
_request: *const ExperimentalWebAuthnPluginCancelOperationRequest,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl IClassFactory_Impl for Factory_Impl {
|
||||
fn CreateInstance(
|
||||
&self,
|
||||
outer: Ref<IUnknown>,
|
||||
iid: *const GUID,
|
||||
object: *mut *mut core::ffi::c_void,
|
||||
) -> Result<()> {
|
||||
assert!(outer.is_null());
|
||||
let unknown: IInspectable = PluginAuthenticatorComObject.into();
|
||||
unsafe { unknown.query(iid, object).ok() }
|
||||
}
|
||||
|
||||
fn LockServer(&self, lock: BOOL) -> Result<()> {
|
||||
assert!(lock.as_bool());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// User verification requirement as defined by WebAuthn spec
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UserVerificationRequirement {
|
||||
Required,
|
||||
Preferred,
|
||||
Discouraged,
|
||||
}
|
||||
|
||||
impl Default for UserVerificationRequirement {
|
||||
fn default() -> Self {
|
||||
UserVerificationRequirement::Preferred
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u32> for UserVerificationRequirement {
|
||||
fn from(value: u32) -> Self {
|
||||
match value {
|
||||
1 => UserVerificationRequirement::Required,
|
||||
2 => UserVerificationRequirement::Preferred,
|
||||
3 => UserVerificationRequirement::Discouraged,
|
||||
_ => UserVerificationRequirement::Preferred, // Default fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<String> for UserVerificationRequirement {
|
||||
fn into(self) -> String {
|
||||
match self {
|
||||
UserVerificationRequirement::Required => "required".to_string(),
|
||||
UserVerificationRequirement::Preferred => "preferred".to_string(),
|
||||
UserVerificationRequirement::Discouraged => "discouraged".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use std::fs::{create_dir_all, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::System::LibraryLoader::*;
|
||||
use windows_core::*;
|
||||
|
||||
use crate::com_buffer::ComBuffer;
|
||||
|
||||
pub unsafe fn delay_load<T>(library: PCSTR, function: PCSTR) -> Option<T> {
|
||||
let library = LoadLibraryExA(library, None, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
|
||||
|
||||
let Ok(library) = library else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let address = GetProcAddress(library, function);
|
||||
|
||||
if address.is_some() {
|
||||
return Some(std::mem::transmute_copy(&address));
|
||||
}
|
||||
|
||||
_ = FreeLibrary(library);
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Trait for converting strings to Windows-compatible wide strings using COM allocation
|
||||
pub trait WindowsString {
|
||||
/// Converts to null-terminated UTF-16 using COM allocation
|
||||
fn to_com_utf16(&self) -> (*mut u16, u32);
|
||||
/// Converts to Vec<u16> for temporary use (caller must keep Vec alive)
|
||||
fn to_utf16(&self) -> Vec<u16>;
|
||||
}
|
||||
|
||||
impl WindowsString for str {
|
||||
fn to_com_utf16(&self) -> (*mut u16, u32) {
|
||||
let mut wide_vec: Vec<u16> = self.encode_utf16().collect();
|
||||
wide_vec.push(0); // null terminator
|
||||
let wide_bytes: Vec<u8> = wide_vec.iter().flat_map(|&x| x.to_le_bytes()).collect();
|
||||
let (ptr, byte_count) = ComBuffer::from_buffer(&wide_bytes);
|
||||
(ptr as *mut u16, byte_count)
|
||||
}
|
||||
|
||||
fn to_utf16(&self) -> Vec<u16> {
|
||||
let mut wide_vec: Vec<u16> = self.encode_utf16().collect();
|
||||
wide_vec.push(0); // null terminator
|
||||
wide_vec
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_log(msg: &str) {
|
||||
let log_path = "C:\\temp\\bitwarden_com_debug.log";
|
||||
|
||||
// Create the temp directory if it doesn't exist
|
||||
if let Some(parent) = Path::new(log_path).parent() {
|
||||
let _ = create_dir_all(parent);
|
||||
}
|
||||
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) {
|
||||
let now = SystemTime::now();
|
||||
let timestamp = match now.duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => {
|
||||
let total_secs = duration.as_secs();
|
||||
let millis = duration.subsec_millis();
|
||||
let secs = total_secs % 60;
|
||||
let mins = (total_secs / 60) % 60;
|
||||
let hours = (total_secs / 3600) % 24;
|
||||
format!("{:02}:{:02}:{:02}.{:03}", hours, mins, secs, millis)
|
||||
}
|
||||
Err(_) => "??:??:??.???".to_string(),
|
||||
};
|
||||
|
||||
let _ = writeln!(file, "[{}] {}", timestamp, msg);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_log(message: &str) {
|
||||
tracing::debug!(message);
|
||||
file_log(message)
|
||||
}
|
||||
|
||||
// Helper function to convert Windows wide string (UTF-16) to Rust String
|
||||
pub unsafe fn wstr_to_string(
|
||||
wstr_ptr: *const u16,
|
||||
) -> std::result::Result<String, std::string::FromUtf16Error> {
|
||||
if wstr_ptr.is_null() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
// Find the length of the null-terminated wide string
|
||||
let mut len = 0;
|
||||
while *wstr_ptr.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
// Convert to Rust string
|
||||
let wide_slice = std::slice::from_raw_parts(wstr_ptr, len);
|
||||
String::from_utf16(wide_slice)
|
||||
}
|
||||
@@ -1,29 +1,418 @@
|
||||
/*
|
||||
This file exposes the functions and types defined here: https://github.com/microsoft/webauthn/blob/master/experimental/webauthn.h
|
||||
This file exposes safe functions and types for interacting with the stable
|
||||
Windows WebAuthn Plugin API defined here:
|
||||
|
||||
https://github.com/microsoft/webauthn/blob/master/webauthnplugin.h
|
||||
*/
|
||||
|
||||
/// Used when adding a Windows plugin authenticator.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS
|
||||
/// Header File Usage: EXPERIMENTAL_WebAuthNPluginAddAuthenticator()
|
||||
use windows_core::*;
|
||||
|
||||
use crate::com_buffer::ComBuffer;
|
||||
use crate::util::{debug_log, delay_load, WindowsString};
|
||||
|
||||
/// Windows WebAuthn Authenticator Options structure
|
||||
/// Header File Name: _WEBAUTHN_CTAPCBOR_AUTHENTICATOR_OPTIONS
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginAddAuthenticatorOptions {
|
||||
pub authenticator_name: *const u16,
|
||||
pub com_clsid: *const u16,
|
||||
pub rpid: *const u16,
|
||||
pub light_theme_logo: *const u16,
|
||||
pub dark_theme_logo: *const u16,
|
||||
pub cbor_authenticator_info_byte_count: u32,
|
||||
pub cbor_authenticator_info: *const u8,
|
||||
pub struct WebAuthnCtapCborAuthenticatorOptions {
|
||||
pub version: u32, // DWORD dwVersion
|
||||
pub user_presence: i32, // LONG lUp: +1=TRUE, 0=Not defined, -1=FALSE
|
||||
pub user_verification: i32, // LONG lUv: +1=TRUE, 0=Not defined, -1=FALSE
|
||||
pub require_resident_key: i32, // LONG lRequireResidentKey: +1=TRUE, 0=Not defined, -1=FALSE
|
||||
}
|
||||
|
||||
/// Used as a response type when adding a Windows plugin authenticator.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE
|
||||
/// Header File Usage: EXPERIMENTAL_WebAuthNPluginAddAuthenticator()
|
||||
/// EXPERIMENTAL_WebAuthNPluginFreeAddAuthenticatorResponse()
|
||||
/// Used when adding a Windows plugin authenticator (stable API).
|
||||
/// Header File Name: _WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS
|
||||
/// Header File Usage: WebAuthNPluginAddAuthenticator()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginAddAuthenticatorResponse {
|
||||
pub struct WebAuthnPluginAddAuthenticatorOptions {
|
||||
pub authenticator_name: *const u16, // LPCWSTR
|
||||
pub rclsid: *const GUID, // REFCLSID (changed from string)
|
||||
pub rpid: *const u16, // LPCWSTR (optional)
|
||||
pub light_theme_logo_svg: *const u16, // LPCWSTR (optional, base64 SVG)
|
||||
pub dark_theme_logo_svg: *const u16, // LPCWSTR (optional, base64 SVG)
|
||||
pub cbor_authenticator_info_byte_count: u32,
|
||||
pub cbor_authenticator_info: *const u8, // const BYTE*
|
||||
pub supported_rp_ids_count: u32, // NEW in stable
|
||||
pub supported_rp_ids: *const *const u16, // NEW in stable: array of LPCWSTR
|
||||
}
|
||||
|
||||
/// Used as a response type when adding a Windows plugin authenticator (stable API).
|
||||
/// Header File Name: _WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE
|
||||
/// Header File Usage: WebAuthNPluginAddAuthenticator()
|
||||
/// WebAuthNPluginFreeAddAuthenticatorResponse()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WebAuthnPluginAddAuthenticatorResponse {
|
||||
pub plugin_operation_signing_key_byte_count: u32,
|
||||
pub plugin_operation_signing_key: *mut u8,
|
||||
}
|
||||
|
||||
/// Represents a credential.
|
||||
/// Header File Name: _WEBAUTHN_PLUGIN_CREDENTIAL_DETAILS
|
||||
/// Header File Usage: WebAuthNPluginAuthenticatorAddCredentials, etc.
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WebAuthnPluginCredentialDetails {
|
||||
pub credential_id_byte_count: u32,
|
||||
pub credential_id_pointer: *const u8, // Changed to const in stable
|
||||
pub rpid: *const u16, // Changed to const (LPCWSTR)
|
||||
pub rp_friendly_name: *const u16, // Changed to const (LPCWSTR)
|
||||
pub user_id_byte_count: u32,
|
||||
pub user_id_pointer: *const u8, // Changed to const
|
||||
pub user_name: *const u16, // Changed to const (LPCWSTR)
|
||||
pub user_display_name: *const u16, // Changed to const (LPCWSTR)
|
||||
}
|
||||
|
||||
impl WebAuthnPluginCredentialDetails {
|
||||
pub fn create_from_bytes(
|
||||
credential_id: Vec<u8>,
|
||||
rpid: String,
|
||||
rp_friendly_name: String,
|
||||
user_id: Vec<u8>,
|
||||
user_name: String,
|
||||
user_display_name: String,
|
||||
) -> Self {
|
||||
// Allocate credential_id bytes with COM
|
||||
let (credential_id_pointer, credential_id_byte_count) =
|
||||
ComBuffer::from_buffer(&credential_id);
|
||||
|
||||
// Allocate user_id bytes with COM
|
||||
let (user_id_pointer, user_id_byte_count) = ComBuffer::from_buffer(&user_id);
|
||||
|
||||
// Convert strings to null-terminated wide strings using trait methods
|
||||
let (rpid_ptr, _) = rpid.to_com_utf16();
|
||||
let (rp_friendly_name_ptr, _) = rp_friendly_name.to_com_utf16();
|
||||
let (user_name_ptr, _) = user_name.to_com_utf16();
|
||||
let (user_display_name_ptr, _) = user_display_name.to_com_utf16();
|
||||
|
||||
Self {
|
||||
credential_id_byte_count,
|
||||
credential_id_pointer: credential_id_pointer as *const u8,
|
||||
rpid: rpid_ptr as *const u16,
|
||||
rp_friendly_name: rp_friendly_name_ptr as *const u16,
|
||||
user_id_byte_count,
|
||||
user_id_pointer: user_id_pointer as *const u8,
|
||||
user_name: user_name_ptr as *const u16,
|
||||
user_display_name: user_display_name_ptr as *const u16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stable API function signatures - now use REFCLSID and flat arrays
|
||||
pub type WebAuthNPluginAuthenticatorAddCredentialsFnDeclaration =
|
||||
unsafe extern "cdecl" fn(
|
||||
rclsid: *const GUID, // Changed from string to GUID reference
|
||||
cCredentialDetails: u32,
|
||||
pCredentialDetails: *const WebAuthnPluginCredentialDetails, // Flat array, not list
|
||||
) -> HRESULT;
|
||||
|
||||
pub type WebAuthNPluginAuthenticatorRemoveCredentialsFnDeclaration =
|
||||
unsafe extern "cdecl" fn(
|
||||
rclsid: *const GUID,
|
||||
cCredentialDetails: u32,
|
||||
pCredentialDetails: *const WebAuthnPluginCredentialDetails,
|
||||
) -> HRESULT;
|
||||
|
||||
pub type WebAuthNPluginAuthenticatorGetAllCredentialsFnDeclaration =
|
||||
unsafe extern "cdecl" fn(
|
||||
rclsid: *const GUID,
|
||||
pcCredentialDetails: *mut u32, // Out param for count
|
||||
ppCredentialDetailsArray: *mut *mut WebAuthnPluginCredentialDetails, // Out param for array
|
||||
) -> HRESULT;
|
||||
|
||||
pub type WebAuthNPluginAuthenticatorFreeCredentialDetailsArrayFnDeclaration =
|
||||
unsafe extern "cdecl" fn(
|
||||
cCredentialDetails: u32,
|
||||
pCredentialDetailsArray: *mut WebAuthnPluginCredentialDetails,
|
||||
);
|
||||
|
||||
pub type WebAuthNPluginAuthenticatorRemoveAllCredentialsFnDeclaration =
|
||||
unsafe extern "cdecl" fn(rclsid: *const GUID) -> HRESULT;
|
||||
|
||||
pub fn add_credentials(
|
||||
clsid_guid: GUID,
|
||||
credentials: Vec<WebAuthnPluginCredentialDetails>,
|
||||
) -> std::result::Result<(), String> {
|
||||
debug_log("Loading WebAuthNPluginAuthenticatorAddCredentials function...");
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginAuthenticatorAddCredentialsFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginAuthenticatorAddCredentials"),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Some(api) => {
|
||||
debug_log("Function loaded successfully, calling API...");
|
||||
debug_log(&format!("Adding {} credentials", credentials.len()));
|
||||
|
||||
let credential_count = credentials.len() as u32;
|
||||
let credentials_ptr = if credentials.is_empty() {
|
||||
std::ptr::null()
|
||||
} else {
|
||||
credentials.as_ptr()
|
||||
};
|
||||
|
||||
let result = unsafe { api(&clsid_guid, credential_count, credentials_ptr) };
|
||||
|
||||
if result.is_err() {
|
||||
let error_code = result.0;
|
||||
debug_log(&format!("API call failed with HRESULT: 0x{:x}", error_code));
|
||||
return Err(format!(
|
||||
"Error: Error response from WebAuthNPluginAuthenticatorAddCredentials()\nHRESULT: 0x{:x}\n{}",
|
||||
error_code, result.message()
|
||||
));
|
||||
}
|
||||
|
||||
debug_log("API call succeeded");
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
debug_log("Failed to load WebAuthNPluginAuthenticatorAddCredentials function from webauthn.dll");
|
||||
Err(String::from("Error: Can't complete add_credentials(), as the function WebAuthNPluginAuthenticatorAddCredentials can't be loaded."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_credentials(
|
||||
clsid_guid: GUID,
|
||||
credentials: Vec<WebAuthnPluginCredentialDetails>,
|
||||
) -> std::result::Result<(), String> {
|
||||
debug_log("Loading WebAuthNPluginAuthenticatorRemoveCredentials function...");
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginAuthenticatorRemoveCredentialsFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginAuthenticatorRemoveCredentials"),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Some(api) => {
|
||||
debug_log(&format!("Removing {} credentials", credentials.len()));
|
||||
|
||||
let credential_count = credentials.len() as u32;
|
||||
let credentials_ptr = if credentials.is_empty() {
|
||||
std::ptr::null()
|
||||
} else {
|
||||
credentials.as_ptr()
|
||||
};
|
||||
|
||||
let result = unsafe { api(&clsid_guid, credential_count, credentials_ptr) };
|
||||
|
||||
if result.is_err() {
|
||||
return Err(format!(
|
||||
"Error: Error response from WebAuthNPluginAuthenticatorRemoveCredentials()\n{}",
|
||||
result.message()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
None => {
|
||||
Err(String::from("Error: Can't complete remove_credentials(), as the function WebAuthNPluginAuthenticatorRemoveCredentials can't be loaded."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper struct to hold owned credential data
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OwnedCredentialDetails {
|
||||
pub credential_id: Vec<u8>,
|
||||
pub rpid: String,
|
||||
pub rp_friendly_name: String,
|
||||
pub user_id: Vec<u8>,
|
||||
pub user_name: String,
|
||||
pub user_display_name: String,
|
||||
}
|
||||
|
||||
pub fn get_all_credentials(
|
||||
clsid_guid: GUID,
|
||||
) -> std::result::Result<Vec<OwnedCredentialDetails>, String> {
|
||||
debug_log("Loading WebAuthNPluginAuthenticatorGetAllCredentials function...");
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginAuthenticatorGetAllCredentialsFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginAuthenticatorGetAllCredentials"),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Some(api) => {
|
||||
let mut credential_count: u32 = 0;
|
||||
let mut credentials_array_ptr: *mut WebAuthnPluginCredentialDetails = std::ptr::null_mut();
|
||||
|
||||
let result = unsafe { api(&clsid_guid, &mut credential_count, &mut credentials_array_ptr) };
|
||||
|
||||
if result.is_err() {
|
||||
return Err(format!(
|
||||
"Error: Error response from WebAuthNPluginAuthenticatorGetAllCredentials()\n{}",
|
||||
result.message()
|
||||
));
|
||||
}
|
||||
|
||||
if credentials_array_ptr.is_null() || credential_count == 0 {
|
||||
debug_log("No credentials returned");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Deep copy the credential data before Windows frees it
|
||||
let credentials_slice = unsafe {
|
||||
std::slice::from_raw_parts(credentials_array_ptr, credential_count as usize)
|
||||
};
|
||||
|
||||
let mut owned_credentials = Vec::new();
|
||||
for cred in credentials_slice {
|
||||
unsafe {
|
||||
// Copy credential ID bytes
|
||||
let credential_id = if !cred.credential_id_pointer.is_null() && cred.credential_id_byte_count > 0 {
|
||||
std::slice::from_raw_parts(cred.credential_id_pointer, cred.credential_id_byte_count as usize).to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Copy user ID bytes
|
||||
let user_id = if !cred.user_id_pointer.is_null() && cred.user_id_byte_count > 0 {
|
||||
std::slice::from_raw_parts(cred.user_id_pointer, cred.user_id_byte_count as usize).to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Copy string fields
|
||||
let rpid = if !cred.rpid.is_null() {
|
||||
String::from_utf16_lossy(std::slice::from_raw_parts(
|
||||
cred.rpid,
|
||||
(0..).position(|i| *cred.rpid.offset(i) == 0).unwrap_or(0)
|
||||
))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let rp_friendly_name = if !cred.rp_friendly_name.is_null() {
|
||||
String::from_utf16_lossy(std::slice::from_raw_parts(
|
||||
cred.rp_friendly_name,
|
||||
(0..).position(|i| *cred.rp_friendly_name.offset(i) == 0).unwrap_or(0)
|
||||
))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let user_name = if !cred.user_name.is_null() {
|
||||
String::from_utf16_lossy(std::slice::from_raw_parts(
|
||||
cred.user_name,
|
||||
(0..).position(|i| *cred.user_name.offset(i) == 0).unwrap_or(0)
|
||||
))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let user_display_name = if !cred.user_display_name.is_null() {
|
||||
String::from_utf16_lossy(std::slice::from_raw_parts(
|
||||
cred.user_display_name,
|
||||
(0..).position(|i| *cred.user_display_name.offset(i) == 0).unwrap_or(0)
|
||||
))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
owned_credentials.push(OwnedCredentialDetails {
|
||||
credential_id,
|
||||
rpid,
|
||||
rp_friendly_name,
|
||||
user_id,
|
||||
user_name,
|
||||
user_display_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Free the array using the Windows API - this frees everything including strings
|
||||
free_credential_details_array(credential_count, credentials_array_ptr);
|
||||
|
||||
debug_log(&format!("Retrieved {} credentials", owned_credentials.len()));
|
||||
Ok(owned_credentials)
|
||||
},
|
||||
None => {
|
||||
Err(String::from("Error: Can't complete get_all_credentials(), as the function WebAuthNPluginAuthenticatorGetAllCredentials can't be loaded."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn free_credential_details_array(
|
||||
credential_count: u32,
|
||||
credentials_array: *mut WebAuthnPluginCredentialDetails,
|
||||
) {
|
||||
if credentials_array.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginAuthenticatorFreeCredentialDetailsArrayFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginAuthenticatorFreeCredentialDetailsArray"),
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(api) = result {
|
||||
unsafe { api(credential_count, credentials_array) };
|
||||
} else {
|
||||
debug_log("Warning: Could not load WebAuthNPluginAuthenticatorFreeCredentialDetailsArray");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_all_credentials(clsid_guid: GUID) -> std::result::Result<(), String> {
|
||||
debug_log("Loading WebAuthNPluginAuthenticatorRemoveAllCredentials function...");
|
||||
|
||||
let result = unsafe {
|
||||
delay_load::<WebAuthNPluginAuthenticatorRemoveAllCredentialsFnDeclaration>(
|
||||
s!("webauthn.dll"),
|
||||
s!("WebAuthNPluginAuthenticatorRemoveAllCredentials"),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Some(api) => {
|
||||
debug_log("Function loaded successfully, calling API...");
|
||||
|
||||
let result = unsafe { api(&clsid_guid) };
|
||||
|
||||
if result.is_err() {
|
||||
let error_code = result.0;
|
||||
debug_log(&format!("API call failed with HRESULT: 0x{:x}", error_code));
|
||||
|
||||
return Err(format!(
|
||||
"Error: Error response from WebAuthNPluginAuthenticatorRemoveAllCredentials()\nHRESULT: 0x{:x}\n{}",
|
||||
error_code, result.message()
|
||||
));
|
||||
}
|
||||
|
||||
debug_log("API call succeeded");
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
debug_log("Failed to load WebAuthNPluginAuthenticatorRemoveAllCredentials function from webauthn.dll");
|
||||
Err(String::from("Error: Can't complete remove_all_credentials(), as the function WebAuthNPluginAuthenticatorRemoveAllCredentials can't be loaded."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WEBAUTHN_CREDENTIAL_EX {
|
||||
pub dwVersion: u32,
|
||||
pub cbId: u32,
|
||||
pub pbId: *const u8,
|
||||
pub pwszCredentialType: *const u16, // LPCWSTR
|
||||
pub dwTransports: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct WEBAUTHN_CREDENTIAL_LIST {
|
||||
pub cCredentials: u32,
|
||||
pub ppCredentials: *const *const WEBAUTHN_CREDENTIAL_EX,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"**/node_modules/@bitwarden/desktop-napi/index.js",
|
||||
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
|
||||
],
|
||||
"electronVersion": "36.8.1",
|
||||
"electronVersion": "36.9.3",
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
@@ -58,9 +58,10 @@
|
||||
"appx": {
|
||||
"artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}",
|
||||
"backgroundColor": "#175DDC",
|
||||
"customManifestPath": "./custom-appx-manifest.xml",
|
||||
"applicationId": "BitwardenBeta",
|
||||
"identityName": "8bitSolutionsLLC.BitwardenBeta",
|
||||
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",
|
||||
"publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US",
|
||||
"publisherDisplayName": "Bitwarden Inc",
|
||||
"languages": [
|
||||
"en-US",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
|
||||
"extraMetadata": {
|
||||
"name": "bitwarden"
|
||||
},
|
||||
@@ -90,7 +92,8 @@
|
||||
"electronUpdaterCompatibility": ">=0.0.1",
|
||||
"target": ["portable", "nsis-web", "appx"],
|
||||
"signtoolOptions": {
|
||||
"sign": "./sign.js"
|
||||
"sign": "./sign.js",
|
||||
"publisherName": "CN=com.bitwarden.localdevelopment"
|
||||
},
|
||||
"extraFiles": [
|
||||
{
|
||||
@@ -172,9 +175,10 @@
|
||||
"appx": {
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"backgroundColor": "#175DDC",
|
||||
"customManifestPath": "./custom-appx-manifest.xml",
|
||||
"applicationId": "bitwardendesktop",
|
||||
"identityName": "8bitSolutionsLLC.bitwardendesktop",
|
||||
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",
|
||||
"publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US",
|
||||
"publisherDisplayName": "Bitwarden Inc",
|
||||
"languages": [
|
||||
"en-US",
|
||||
|
||||
@@ -8,63 +8,56 @@
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="CredentialProviderViewController" customModule="autofill_extension" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="logoImageView" destination="logoImageView" id="logoImageViewOutlet"/>
|
||||
<outlet property="statusLabel" destination="statusLabel" id="statusLabelOutlet"/>
|
||||
<outlet property="view" destination="1" id="2"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<customView hidden="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1">
|
||||
<rect key="frame" x="0.0" y="0.0" width="378" height="94"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="400" height="120"/>
|
||||
<subviews>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="1uM-r7-H1c">
|
||||
<rect key="frame" x="184" y="3" width="191" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Return Example Password" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="2l4-PO-we5">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent">D</string>
|
||||
<modifierMask key="keyEquivalentModifierMask" command="YES"/>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="passwordSelected:" target="-2" id="yic-EC-GGk"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="NVE-vN-dkz">
|
||||
<rect key="frame" x="114" y="3" width="76" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="6Up-t3-mwm">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
Gw
|
||||
</string>
|
||||
</buttonCell>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="60" id="cP1-hK-9ZX"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<action selector="cancel:" target="-2" id="Qav-AK-DGt"/>
|
||||
</connections>
|
||||
</button>
|
||||
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aNc-0i-CWK">
|
||||
<rect key="frame" x="112" y="63" width="154" height="16"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="left" title="autofill-extension hello" id="0xp-rC-2gr">
|
||||
<font key="font" metaFont="systemBold"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<stackView distribution="fill" orientation="horizontal" alignment="centerY" spacing="20" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="configStackView">
|
||||
<rect key="frame" x="89" y="35" width="223" height="50"/>
|
||||
<subviews>
|
||||
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="logoImageView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="50" id="logoImageHeight"/>
|
||||
<constraint firstAttribute="width" constant="50" id="logoImageWidth"/>
|
||||
</constraints>
|
||||
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyUpOrDown" image="bitwarden-icon" id="logoImageCell"/>
|
||||
</imageView>
|
||||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="statusLabel">
|
||||
<rect key="frame" x="68" y="16" width="157" height="19"/>
|
||||
<textFieldCell key="cell" sendsActionOnEndEditing="YES" alignment="left" title="Enabling Bitwarden..." id="statusLabelCell">
|
||||
<font key="font" metaFont="system" size="16"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</subviews>
|
||||
<visibilityPriorities>
|
||||
<integer value="1000"/>
|
||||
<integer value="1000"/>
|
||||
</visibilityPriorities>
|
||||
<customSpacing>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
</customSpacing>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="1uM-r7-H1c" firstAttribute="leading" secondItem="NVE-vN-dkz" secondAttribute="trailing" constant="8" id="1UO-J1-LbJ"/>
|
||||
<constraint firstItem="NVE-vN-dkz" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="1" secondAttribute="leading" constant="20" symbolic="YES" id="3N9-qo-UfS"/>
|
||||
<constraint firstAttribute="bottom" secondItem="1uM-r7-H1c" secondAttribute="bottom" constant="10" id="4wH-De-nMF"/>
|
||||
<constraint firstItem="NVE-vN-dkz" firstAttribute="firstBaseline" secondItem="aNc-0i-CWK" secondAttribute="baseline" constant="50" id="Dpq-cK-cPE"/>
|
||||
<constraint firstAttribute="bottom" secondItem="NVE-vN-dkz" secondAttribute="bottom" constant="10" id="USG-Gg-of3"/>
|
||||
<constraint firstItem="1uM-r7-H1c" firstAttribute="leading" secondItem="NVE-vN-dkz" secondAttribute="trailing" constant="8" id="a8N-vS-Ew9"/>
|
||||
<constraint firstAttribute="trailing" secondItem="1uM-r7-H1c" secondAttribute="trailing" constant="10" id="qfT-cw-QQ2"/>
|
||||
<constraint firstAttribute="centerX" secondItem="aNc-0i-CWK" secondAttribute="centerX" id="uV3-Wn-RA3"/>
|
||||
<constraint firstItem="aNc-0i-CWK" firstAttribute="top" secondItem="1" secondAttribute="top" constant="15" id="vpR-tf-ebx"/>
|
||||
<constraint firstItem="configStackView" firstAttribute="centerX" secondItem="1" secondAttribute="centerX" id="stackCenterX"/>
|
||||
<constraint firstItem="configStackView" firstAttribute="centerY" secondItem="1" secondAttribute="centerY" id="stackCenterY"/>
|
||||
<constraint firstItem="configStackView" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="1" secondAttribute="leading" constant="20" id="stackLeading"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="configStackView" secondAttribute="trailing" constant="20" id="stackTrailing"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="162" y="146"/>
|
||||
<point key="canvasLocation" x="200" y="60"/>
|
||||
</customView>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="bitwarden-icon" width="64" height="64"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -11,63 +11,138 @@ import os
|
||||
class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
let logger: Logger
|
||||
|
||||
// There is something a bit strange about the initialization/deinitialization in this class.
|
||||
// Sometimes deinit won't be called after a request has successfully finished,
|
||||
// which would leave this class hanging in memory and the IPC connection open.
|
||||
//
|
||||
// If instead I make this a static, the deinit gets called correctly after each request.
|
||||
// I think we still might want a static regardless, to be able to reuse the connection if possible.
|
||||
let client: MacOsProviderClient = {
|
||||
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
|
||||
@IBOutlet weak var statusLabel: NSTextField!
|
||||
@IBOutlet weak var logoImageView: NSImageView!
|
||||
|
||||
// The IPC client to communicate with the Bitwarden desktop app
|
||||
private var client: MacOsProviderClient?
|
||||
|
||||
// Timer for checking connection status
|
||||
private var connectionMonitorTimer: Timer?
|
||||
private var lastConnectionStatus: ConnectionStatus = .disconnected
|
||||
|
||||
// We changed the getClient method to be async, here's why:
|
||||
// This is so that we can check if the app is running, and launch it, without blocking the main thread
|
||||
// Blocking the main thread caused MacOS layouting to 'fail' or at least be very delayed, which caused our getWindowPositioning code to sent 0,0.
|
||||
// We also properly retry the IPC connection which sometimes would take some time to be up and running, depending on CPU load, phase of jupiters moon, etc.
|
||||
private func getClient() async -> MacOsProviderClient {
|
||||
if let client = self.client {
|
||||
return client
|
||||
}
|
||||
|
||||
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
|
||||
|
||||
// Check if the Electron app is running
|
||||
let workspace = NSWorkspace.shared
|
||||
let isRunning = workspace.runningApplications.contains { app in
|
||||
app.bundleIdentifier == "com.bitwarden.desktop"
|
||||
}
|
||||
|
||||
|
||||
if !isRunning {
|
||||
logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch")
|
||||
|
||||
// Try to launch the app
|
||||
logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch")
|
||||
|
||||
// Launch the app and wait for it to be ready
|
||||
if let appURL = workspace.urlForApplication(withBundleIdentifier: "com.bitwarden.desktop") {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
workspace.openApplication(at: appURL,
|
||||
configuration: NSWorkspace.OpenConfiguration()) { app, error in
|
||||
if let error = error {
|
||||
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)")
|
||||
} else if let app = app {
|
||||
logger.log("[autofill-extension] Successfully launched Bitwarden Desktop")
|
||||
} else {
|
||||
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: unknown error")
|
||||
await withCheckedContinuation { continuation in
|
||||
workspace.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) { app, error in
|
||||
if let error = error {
|
||||
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)")
|
||||
} else {
|
||||
logger.log("[autofill-extension] Successfully launched Bitwarden Desktop")
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
// Wait for launch completion with timeout
|
||||
_ = semaphore.wait(timeout: .now() + 5.0)
|
||||
|
||||
// Add a small delay to allow for initialization
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
} else {
|
||||
logger.error("[autofill-extension] Could not find Bitwarden Desktop app")
|
||||
}
|
||||
} else {
|
||||
logger.log("[autofill-extension] Bitwarden Desktop is running")
|
||||
}
|
||||
|
||||
logger.log("[autofill-extension] Connecting to Bitwarden over IPC")
|
||||
|
||||
// Retry connecting to the Bitwarden IPC with an increasing delay
|
||||
let maxRetries = 20
|
||||
let delayMs = 500
|
||||
var newClient: MacOsProviderClient?
|
||||
|
||||
for attempt in 1...maxRetries {
|
||||
logger.log("[autofill-extension] Connection attempt \(attempt)")
|
||||
|
||||
// Create a new client instance for each retry
|
||||
newClient = MacOsProviderClient.connect()
|
||||
try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds
|
||||
let connectionStatus = newClient!.getConnectionStatus()
|
||||
|
||||
logger.log("[autofill-extension] Connection attempt \(attempt), status: \(connectionStatus == .connected ? "connected" : "disconnected")")
|
||||
|
||||
if connectionStatus == .connected {
|
||||
logger.log("[autofill-extension] Successfully connected to Bitwarden (attempt \(attempt))")
|
||||
break
|
||||
} else {
|
||||
if attempt < maxRetries {
|
||||
logger.log("[autofill-extension] Retrying connection")
|
||||
} else {
|
||||
logger.error("[autofill-extension] Failed to connect after \(maxRetries) attempts, final status: \(connectionStatus == .connected ? "connected" : "disconnected")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.log("[autofill-extension] Connecting to Bitwarden over IPC")
|
||||
|
||||
return MacOsProviderClient.connect()
|
||||
}()
|
||||
self.client = newClient
|
||||
return newClient!
|
||||
}
|
||||
|
||||
// Setup the connection monitoring timer
|
||||
private func setupConnectionMonitoring() {
|
||||
// Check connection status every 1 second
|
||||
connectionMonitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
self?.checkConnectionStatus()
|
||||
}
|
||||
|
||||
// Make sure timer runs even when UI is busy
|
||||
RunLoop.current.add(connectionMonitorTimer!, forMode: .common)
|
||||
|
||||
// Initial check
|
||||
checkConnectionStatus()
|
||||
}
|
||||
|
||||
// Check the connection status by calling into Rust
|
||||
// If the connection is has changed and is now disconnected, cancel the request
|
||||
private func checkConnectionStatus() {
|
||||
// Only check connection status if the client has been initialized.
|
||||
// Initialization is done asynchronously, so we might be called before it's ready
|
||||
// In that case we just skip this check and wait for the next timer tick and re-check
|
||||
guard let client = self.client else {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current connection status from Rust
|
||||
let currentStatus = client.getConnectionStatus()
|
||||
|
||||
// Only post notification if state changed
|
||||
if currentStatus != lastConnectionStatus {
|
||||
if(currentStatus == .connected) {
|
||||
logger.log("[autofill-extension] Connection status changed: Connected")
|
||||
} else {
|
||||
logger.log("[autofill-extension] Connection status changed: Disconnected")
|
||||
}
|
||||
|
||||
// Save the new status
|
||||
lastConnectionStatus = currentStatus
|
||||
|
||||
// If we just disconnected, try to cancel the request
|
||||
if currentStatus == .disconnected {
|
||||
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
|
||||
|
||||
logger.log("[autofill-extension] initializing extension")
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
super.init(nibName: "CredentialProviderViewController", bundle: nil)
|
||||
|
||||
// Setup connection monitoring now that self is available
|
||||
setupConnectionMonitoring()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@@ -76,45 +151,114 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
|
||||
deinit {
|
||||
logger.log("[autofill-extension] deinitializing extension")
|
||||
}
|
||||
|
||||
|
||||
@IBAction func cancel(_ sender: AnyObject?) {
|
||||
self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue))
|
||||
}
|
||||
|
||||
@IBAction func passwordSelected(_ sender: AnyObject?) {
|
||||
let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234")
|
||||
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
|
||||
}
|
||||
|
||||
private func getWindowPosition() -> Position {
|
||||
let frame = self.view.window?.frame ?? .zero
|
||||
let screenHeight = NSScreen.main?.frame.height ?? 0
|
||||
|
||||
// frame.width and frame.height is always 0. Estimating works OK for now.
|
||||
let estimatedWidth:CGFloat = 400;
|
||||
let estimatedHeight:CGFloat = 200;
|
||||
let centerX = Int32(round(frame.origin.x + estimatedWidth/2))
|
||||
let centerY = Int32(round(screenHeight - (frame.origin.y + estimatedHeight/2)))
|
||||
|
||||
return Position(x: centerX, y:centerY)
|
||||
// Stop the connection monitor timer
|
||||
connectionMonitorTimer?.invalidate()
|
||||
connectionMonitorTimer = nil
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
let view = NSView()
|
||||
// Hide the native window since we only need the IPC connection
|
||||
view.isHidden = true
|
||||
self.view = view
|
||||
private func getWindowPosition() async -> Position {
|
||||
let screenHeight = NSScreen.main?.frame.height ?? 1440
|
||||
|
||||
logger.log("[autofill-extension] position: Getting window position")
|
||||
|
||||
// To whomever is reading this. Sorry. But MacOS couldn't give us an accurate window positioning, possibly due to animations
|
||||
// So I added some retry logic, as well as a fall back to the mouse position which is likely at the sort of the right place.
|
||||
// In my testing we often succed after 4-7 attempts.
|
||||
// Wait for window frame to stabilize (animation to complete)
|
||||
var lastFrame: CGRect = .zero
|
||||
var stableCount = 0
|
||||
let requiredStableChecks = 3
|
||||
let maxAttempts = 20
|
||||
var attempts = 0
|
||||
|
||||
while stableCount < requiredStableChecks && attempts < maxAttempts {
|
||||
let currentFrame: CGRect = self.view.window?.frame ?? .zero
|
||||
|
||||
if currentFrame.equalTo(lastFrame) && !currentFrame.equalTo(.zero) {
|
||||
stableCount += 1
|
||||
} else {
|
||||
stableCount = 0
|
||||
lastFrame = currentFrame
|
||||
}
|
||||
|
||||
try? await Task.sleep(nanoseconds: 16_666_666) // ~60fps (16.67ms)
|
||||
attempts += 1
|
||||
}
|
||||
|
||||
let finalWindowFrame = self.view.window?.frame ?? .zero
|
||||
logger.log("[autofill-extension] position: Final window frame: \(NSStringFromRect(finalWindowFrame))")
|
||||
|
||||
// Use stabilized window frame if available, otherwise fallback to mouse position
|
||||
let x, y: Int32
|
||||
if finalWindowFrame.origin.x != 0 || finalWindowFrame.origin.y != 0 {
|
||||
let centerX = Int32(round(finalWindowFrame.origin.x))
|
||||
let centerY = Int32(round(screenHeight - finalWindowFrame.origin.y))
|
||||
logger.log("[autofill-extension] position: Using window position: x=\(centerX), y=\(centerY)")
|
||||
x = centerX
|
||||
y = centerY
|
||||
} else {
|
||||
// Fallback to mouse position
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
let mouseX = Int32(round(mouseLocation.x))
|
||||
let mouseY = Int32(round(screenHeight - mouseLocation.y))
|
||||
logger.log("[autofill-extension] position: Using mouse position fallback: x=\(mouseX), y=\(mouseY)")
|
||||
x = mouseX
|
||||
y = mouseY
|
||||
}
|
||||
// Add 100 pixels to the x-coordinate to offset the native OS dialog positioning.
|
||||
return Position(x: x + 100, y: y)
|
||||
}
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Initially hide the view
|
||||
self.view.isHidden = true
|
||||
}
|
||||
|
||||
override func prepareInterfaceForExtensionConfiguration() {
|
||||
// Show the configuration UI
|
||||
self.view.isHidden = false
|
||||
|
||||
// Set the localized message
|
||||
statusLabel.stringValue = NSLocalizedString("autofillConfigurationMessage", comment: "Message shown when Bitwarden is enabled in system settings")
|
||||
|
||||
// Send the native status request asynchronously
|
||||
Task {
|
||||
let client = await getClient()
|
||||
client.sendNativeStatus(key: "request-sync", value: "")
|
||||
}
|
||||
|
||||
// Complete the configuration after 2 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
||||
self?.extensionContext.completeExtensionConfigurationRequest()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
In order to implement this method, we need to query the state of the vault to be unlocked and have one and only one matching credential so that it doesn't need to show ui.
|
||||
If we do show UI, it's going to fail and disconnect after the platform timeout which is 3s.
|
||||
For now we just claim to always need UI displayed.
|
||||
*/
|
||||
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
|
||||
let error = ASExtensionError(.userInteractionRequired)
|
||||
self.extensionContext.cancelRequest(withError: error)
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with
|
||||
ASExtensionError.userInteractionRequired. In this case, the system may present your extension's
|
||||
UI and call this method. Show appropriate UI for authenticating the user then provide the password
|
||||
by completing the extension request with the associated ASPasswordCredential.
|
||||
*/
|
||||
override func prepareInterfaceToProvideCredential(for credentialRequest: ASCredentialRequest) {
|
||||
let timeoutTimer = createTimer()
|
||||
|
||||
if let request = credentialRequest as? ASPasskeyCredentialRequest {
|
||||
if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity {
|
||||
|
||||
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(passkey) called \(request)")
|
||||
|
||||
logger.log("[autofill-extension] prepareInterfaceToProvideCredential (passkey) called \(request)")
|
||||
|
||||
class CallbackImpl: PreparePasskeyAssertionCallback {
|
||||
let ctx: ASCredentialProviderExtensionContext
|
||||
@@ -154,18 +298,25 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
UserVerification.discouraged
|
||||
}
|
||||
|
||||
let req = PasskeyAssertionWithoutUserInterfaceRequest(
|
||||
rpId: passkeyIdentity.relyingPartyIdentifier,
|
||||
credentialId: passkeyIdentity.credentialID,
|
||||
userName: passkeyIdentity.userName,
|
||||
userHandle: passkeyIdentity.userHandle,
|
||||
recordIdentifier: passkeyIdentity.recordIdentifier,
|
||||
clientDataHash: request.clientDataHash,
|
||||
userVerification: userVerification,
|
||||
windowXy: self.getWindowPosition()
|
||||
)
|
||||
|
||||
self.client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
|
||||
/*
|
||||
We're still using the old request type here, because we're sending the same data, we're expecting a single credential to be used
|
||||
*/
|
||||
Task {
|
||||
let windowPosition = await self.getWindowPosition()
|
||||
let req = PasskeyAssertionWithoutUserInterfaceRequest(
|
||||
rpId: passkeyIdentity.relyingPartyIdentifier,
|
||||
credentialId: passkeyIdentity.credentialID,
|
||||
userName: passkeyIdentity.userName,
|
||||
userHandle: passkeyIdentity.userHandle,
|
||||
recordIdentifier: passkeyIdentity.recordIdentifier,
|
||||
clientDataHash: request.clientDataHash,
|
||||
userVerification: userVerification,
|
||||
windowXy: windowPosition
|
||||
)
|
||||
|
||||
let client = await getClient()
|
||||
client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -176,16 +327,6 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request"))
|
||||
}
|
||||
|
||||
/*
|
||||
Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with
|
||||
ASExtensionError.userInteractionRequired. In this case, the system may present your extension's
|
||||
UI and call this method. Show appropriate UI for authenticating the user then provide the password
|
||||
by completing the extension request with the associated ASPasswordCredential.
|
||||
|
||||
override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) {
|
||||
}
|
||||
*/
|
||||
|
||||
private func createTimer() -> DispatchWorkItem {
|
||||
// Create a timer for 600 second timeout
|
||||
let timeoutTimer = DispatchWorkItem { [weak self] in
|
||||
@@ -246,18 +387,32 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
UserVerification.discouraged
|
||||
}
|
||||
|
||||
let req = PasskeyRegistrationRequest(
|
||||
rpId: passkeyIdentity.relyingPartyIdentifier,
|
||||
userName: passkeyIdentity.userName,
|
||||
userHandle: passkeyIdentity.userHandle,
|
||||
clientDataHash: request.clientDataHash,
|
||||
userVerification: userVerification,
|
||||
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) },
|
||||
windowXy: self.getWindowPosition()
|
||||
)
|
||||
// Convert excluded credentials to an array of credential IDs
|
||||
var excludedCredentialIds: [Data] = []
|
||||
if #available(macOSApplicationExtension 15.0, *) {
|
||||
if let excludedCreds = request.excludedCredentials {
|
||||
excludedCredentialIds = excludedCreds.map { $0.credentialID }
|
||||
}
|
||||
}
|
||||
|
||||
logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration")
|
||||
|
||||
self.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
|
||||
Task {
|
||||
let windowPosition = await self.getWindowPosition()
|
||||
let req = PasskeyRegistrationRequest(
|
||||
rpId: passkeyIdentity.relyingPartyIdentifier,
|
||||
userName: passkeyIdentity.userName,
|
||||
userHandle: passkeyIdentity.userHandle,
|
||||
clientDataHash: request.clientDataHash,
|
||||
userVerification: userVerification,
|
||||
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) },
|
||||
windowXy: windowPosition,
|
||||
excludedCredentials: excludedCredentialIds
|
||||
)
|
||||
|
||||
let client = await getClient()
|
||||
client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -310,18 +465,22 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
UserVerification.discouraged
|
||||
}
|
||||
|
||||
let req = PasskeyAssertionRequest(
|
||||
rpId: requestParameters.relyingPartyIdentifier,
|
||||
clientDataHash: requestParameters.clientDataHash,
|
||||
userVerification: userVerification,
|
||||
allowedCredentials: requestParameters.allowedCredentials,
|
||||
windowXy: self.getWindowPosition()
|
||||
//extensionInput: requestParameters.extensionInput,
|
||||
)
|
||||
|
||||
let timeoutTimer = createTimer()
|
||||
|
||||
self.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
|
||||
Task {
|
||||
let windowPosition = await self.getWindowPosition()
|
||||
let req = PasskeyAssertionRequest(
|
||||
rpId: requestParameters.relyingPartyIdentifier,
|
||||
clientDataHash: requestParameters.clientDataHash,
|
||||
userVerification: userVerification,
|
||||
allowedCredentials: requestParameters.allowedCredentials,
|
||||
windowXy: windowPosition
|
||||
//extensionInput: requestParameters.extensionInput, // We don't support extensions yet
|
||||
)
|
||||
|
||||
let client = await getClient()
|
||||
client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<dict>
|
||||
<key>ProvidesPasskeys</key>
|
||||
<true/>
|
||||
<key>ShowsConfigurationUI</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>ASCredentialProviderExtensionShowsConfigurationUI</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.authentication-services-credential-provider-ui</string>
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
|
||||
</array>
|
||||
|
||||
BIN
apps/desktop/macos/autofill-extension/bitwarden-icon.png
Normal file
BIN
apps/desktop/macos/autofill-extension/bitwarden-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,2 @@
|
||||
/* Message shown during passkey configuration */
|
||||
"autofillConfigurationMessage" = "Enabling Bitwarden...";
|
||||
@@ -9,6 +9,8 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
3368DB392C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */; };
|
||||
3368DB3B2C654F3800896B75 /* BitwardenMacosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */; };
|
||||
9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */; };
|
||||
9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9AE2990D2DFB57A200AAE454 /* Localizable.strings */; };
|
||||
E1DF713F2B342F6900F29026 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */; };
|
||||
E1DF71422B342F6900F29026 /* CredentialProviderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */; };
|
||||
E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */; };
|
||||
@@ -18,6 +20,8 @@
|
||||
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; };
|
||||
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = "<group>"; };
|
||||
968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = "<group>"; };
|
||||
9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bitwarden-icon.png"; sourceTree = "<group>"; };
|
||||
9AE2990C2DFB57A200AAE454 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseDeveloper.xcconfig; sourceTree = "<group>"; };
|
||||
E1DF713C2B342F6900F29026 /* autofill-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "autofill-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -41,6 +45,14 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
9AE2990E2DFB57A200AAE454 /* en.lproj */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9AE2990D2DFB57A200AAE454 /* Localizable.strings */,
|
||||
);
|
||||
path = en.lproj;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1DF711D2B342E2800F29026 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -73,6 +85,8 @@
|
||||
E1DF71402B342F6900F29026 /* autofill-extension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9AE2990E2DFB57A200AAE454 /* en.lproj */,
|
||||
9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */,
|
||||
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */,
|
||||
E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */,
|
||||
E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */,
|
||||
@@ -124,6 +138,7 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
sv,
|
||||
);
|
||||
mainGroup = E1DF711D2B342E2800F29026;
|
||||
productRefGroup = E1DF71272B342E2800F29026 /* Products */;
|
||||
@@ -141,6 +156,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */,
|
||||
9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */,
|
||||
9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -159,6 +176,14 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
9AE2990D2DFB57A200AAE454 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
9AE2990C2DFB57A200AAE454 /* en */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"scripts": {
|
||||
"postinstall": "electron-rebuild",
|
||||
"start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build",
|
||||
"build-native-macos": "cd desktop_native && ./macos_provider/build.sh && node build.js cross-platform",
|
||||
"build-native": "cd desktop_native && node build.js",
|
||||
"build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"",
|
||||
"build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"",
|
||||
@@ -28,7 +29,7 @@
|
||||
"build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas",
|
||||
"build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev",
|
||||
"build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main",
|
||||
"build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main",
|
||||
"build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main",
|
||||
"build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch",
|
||||
"build:renderer": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer",
|
||||
"build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer",
|
||||
@@ -41,25 +42,21 @@
|
||||
"pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
|
||||
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/",
|
||||
"pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
|
||||
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
|
||||
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
|
||||
"pack:mac": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
|
||||
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",
|
||||
"pack:mac:mas": "npm run clean:dist && electron-builder --mac mas --universal -p never",
|
||||
"pack:mac:mas:with-extension": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never",
|
||||
"pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never",
|
||||
"pack:mac:masdev:with-extension": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
|
||||
"pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never",
|
||||
"pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
|
||||
"pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"",
|
||||
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
|
||||
"pack:win:arm64": "npm run clean:dist && electron-builder --win --arm64 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
|
||||
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
|
||||
"pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
|
||||
"dist:dir": "npm run build && npm run pack:dir",
|
||||
"dist:lin": "npm run build && npm run pack:lin",
|
||||
"dist:lin:arm64": "npm run build && npm run pack:lin:arm64",
|
||||
"dist:mac": "npm run build && npm run pack:mac",
|
||||
"dist:mac:with-extension": "npm run build && npm run pack:mac:with-extension",
|
||||
"dist:mac:mas": "npm run build && npm run pack:mac:mas",
|
||||
"dist:mac:mas:with-extension": "npm run build && npm run pack:mac:mas:with-extension",
|
||||
"dist:mac:masdev": "npm run build:dev && npm run pack:mac:masdev",
|
||||
"dist:mac:masdev:with-extension": "npm run build:dev && npm run pack:mac:masdev:with-extension",
|
||||
"dist:mac:masdev": "npm run build && npm run pack:mac:masdev",
|
||||
"dist:win": "npm run build && npm run pack:win",
|
||||
"dist:win:ci": "npm run build && npm run pack:win:ci",
|
||||
"publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always",
|
||||
@@ -70,6 +67,7 @@
|
||||
"upload:mas": "xcrun altool --upload-app --type osx --file \"$(find ./dist/mas-universal/Bitwarden*.pkg)\" --apiKey $APP_STORE_CONNECT_AUTH_KEY --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:watch:all": "jest --watchAll"
|
||||
"test:watch:all": "jest --watchAll",
|
||||
"local:win": "cd desktop_native/napi && npm run build && cd ../.. && npm run build:dev && npm run pack:win"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
|
||||
<key>com.apple.developer.team-identifier</key>
|
||||
<string>LTZ2PFU5D6</string>
|
||||
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -6,19 +6,19 @@
|
||||
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
|
||||
<key>com.apple.developer.team-identifier</key>
|
||||
<string>LTZ2PFU5D6</string>
|
||||
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
|
||||
<array>
|
||||
@@ -32,10 +32,8 @@
|
||||
<string>/Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
|
||||
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
|
||||
</array>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, no-console */
|
||||
|
||||
exports.default = async function (configuration) {
|
||||
if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && configuration.path.slice(-4) == ".exe") {
|
||||
if (
|
||||
parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 &&
|
||||
(configuration.path.endsWith(".exe") ||
|
||||
configuration.path.endsWith(".appx") ||
|
||||
configuration.path.endsWith(".msix"))
|
||||
) {
|
||||
console.log(`[*] Signing file: ${configuration.path}`);
|
||||
|
||||
// If signing APPX/MSIX, inspect the manifest Publisher before signing
|
||||
if (configuration.path.endsWith(".appx") || configuration.path.endsWith(".msix")) {
|
||||
try {
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Extract architecture from filename (e.g., "Bitwarden-2025.10.2-x64.appx" -> "x64")
|
||||
const filename = path.basename(configuration.path);
|
||||
const archMatch = filename.match(/-(x64|arm64|ia32)\.(appx|msix)$/);
|
||||
|
||||
if (archMatch) {
|
||||
const arch = archMatch[1];
|
||||
const distDir = path.dirname(configuration.path);
|
||||
const manifestPath = path.join(distDir, `__appx-${arch}`, "AppxManifest.xml");
|
||||
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const manifestContent = fs.readFileSync(manifestPath, "utf8");
|
||||
|
||||
// Extract and display the Publisher line
|
||||
const publisherMatch = manifestContent.match(/Publisher='([^']+)'/);
|
||||
if (publisherMatch) {
|
||||
console.log(`[*] APPX Manifest Publisher: ${publisherMatch[1]}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[!] Manifest not found at: ${manifestPath}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[!] Failed to read manifest: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
require("child_process").execSync(
|
||||
`azuresigntool sign -v ` +
|
||||
`-kvu ${process.env.SIGNING_VAULT_URL} ` +
|
||||
@@ -18,5 +56,20 @@ exports.default = async function (configuration) {
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
} else if (process.env.ELECTRON_BUILDER_SIGN_CERT) {
|
||||
const certFile = process.env.ELECTRON_BUILDER_SIGN_CERT;
|
||||
const certPw = process.env.ELECTRON_BUILDER_SIGN_CERT_PW;
|
||||
console.log(`[*] Signing file: ${configuration.path} with ${certFile}`);
|
||||
require("child_process").execSync(
|
||||
"signtool.exe sign" +
|
||||
" /fd SHA256" +
|
||||
" /a" +
|
||||
` /f "${certFile}"` +
|
||||
` /p "${certPw}"` +
|
||||
` "${configuration.path}"`,
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
BIN
apps/desktop/sign.ps1
Normal file
BIN
apps/desktop/sign.ps1
Normal file
Binary file not shown.
@@ -43,10 +43,13 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co
|
||||
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
|
||||
import { reactiveUnlockVaultGuard } from "../autofill/guards/reactive-vault-guard";
|
||||
import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create.component";
|
||||
import { Fido2ExcludedCiphersComponent } from "../autofill/modal/credentials/fido2-excluded-ciphers.component";
|
||||
import { Fido2VaultComponent } from "../autofill/modal/credentials/fido2-vault.component";
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
|
||||
|
||||
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
|
||||
import { SendComponent } from "./tools/send/send.component";
|
||||
|
||||
/**
|
||||
@@ -112,12 +115,16 @@ const routes: Routes = [
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{
|
||||
path: "passkeys",
|
||||
component: Fido2PlaceholderComponent,
|
||||
path: "fido2-assertion",
|
||||
component: Fido2VaultComponent,
|
||||
},
|
||||
{
|
||||
path: "passkeys",
|
||||
component: Fido2PlaceholderComponent,
|
||||
path: "fido2-creation",
|
||||
component: Fido2CreateComponent,
|
||||
},
|
||||
{
|
||||
path: "fido2-excluded",
|
||||
component: Fido2ExcludedCiphersComponent,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
@@ -263,7 +270,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: "lock",
|
||||
canActivate: [lockGuard()],
|
||||
canActivate: [lockGuard(), reactiveUnlockVaultGuard],
|
||||
data: {
|
||||
pageIcon: LockIcon,
|
||||
pageTitle: {
|
||||
|
||||
@@ -103,7 +103,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
|
||||
<ng-template #exportVault></ng-template>
|
||||
<ng-template #appGenerator></ng-template>
|
||||
<ng-template #loginApproval></ng-template>
|
||||
<app-header></app-header>
|
||||
<app-header *ngIf="showHeader$ | async"></app-header>
|
||||
|
||||
<div id="container">
|
||||
<div class="loading" *ngIf="loading">
|
||||
@@ -140,6 +140,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("loginApproval", { read: ViewContainerRef, static: true })
|
||||
loginApprovalModalRef: ViewContainerRef;
|
||||
|
||||
showHeader$ = this.accountService.showHeader$;
|
||||
loading = false;
|
||||
|
||||
private lastActivity: Date = null;
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../autofill/services/desktop-fido2-user-interface.service";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
|
||||
>
|
||||
<h1 style="color: black">Select your passkey</h1>
|
||||
|
||||
<div *ngFor="let item of cipherIds$ | async">
|
||||
<button
|
||||
style="color:black; padding: 10px 20px; border: 1px solid blue; margin: 10px"
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
(click)="chooseCipher(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<button
|
||||
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
(click)="confirmPasskey()"
|
||||
>
|
||||
Confirm passkey
|
||||
</button>
|
||||
<button
|
||||
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
(click)="closeModal()"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
|
||||
session?: DesktopFido2UserInterfaceSession = null;
|
||||
private cipherIdsSubject = new BehaviorSubject<string[]>([]);
|
||||
cipherIds$: Observable<string[]>;
|
||||
|
||||
constructor(
|
||||
private readonly desktopSettingsService: DesktopSettingsService,
|
||||
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.session = this.fido2UserInterfaceService.getCurrentSession();
|
||||
this.cipherIds$ = this.session?.availableCipherIds$;
|
||||
}
|
||||
|
||||
async chooseCipher(cipherId: string) {
|
||||
// For now: Set UV to true
|
||||
this.session?.confirmChosenCipher(cipherId, true);
|
||||
|
||||
await this.router.navigate(["/"]);
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject
|
||||
}
|
||||
|
||||
async confirmPasskey() {
|
||||
try {
|
||||
// Retrieve the current UI session to control the flow
|
||||
if (!this.session) {
|
||||
// todo: handle error
|
||||
throw new Error("No session found");
|
||||
}
|
||||
|
||||
// If we want to we could submit information to the session in order to create the credential
|
||||
// const cipher = await session.createCredential({
|
||||
// userHandle: "userHandle2",
|
||||
// userName: "username2",
|
||||
// credentialName: "zxsd2",
|
||||
// rpId: "webauthn.io",
|
||||
// userVerification: true,
|
||||
// });
|
||||
|
||||
this.session.notifyConfirmNewCredential(true);
|
||||
|
||||
// Not sure this clean up should happen here or in session.
|
||||
// The session currently toggles modal on and send us here
|
||||
// But if this route is somehow opened outside of session we want to make sure we clean up?
|
||||
await this.router.navigate(["/"]);
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
} catch {
|
||||
// TODO: Handle error appropriately
|
||||
}
|
||||
}
|
||||
|
||||
async closeModal() {
|
||||
await this.router.navigate(["/"]);
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
|
||||
this.session.notifyConfirmNewCredential(false);
|
||||
// little bit hacky:
|
||||
this.session.confirmChosenCipher(null);
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,7 @@ const safeProviders: SafeProvider[] = [
|
||||
ConfigService,
|
||||
Fido2AuthenticatorServiceAbstraction,
|
||||
AccountService,
|
||||
AuthService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
42
apps/desktop/src/autofill/guards/reactive-vault-guard.ts
Normal file
42
apps/desktop/src/autofill/guards/reactive-vault-guard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { combineLatest, map, switchMap, distinctUntilChanged } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
|
||||
/**
|
||||
* Reactive route guard that redirects to the unlocked vault.
|
||||
* Redirects to vault when unlocked in main window.
|
||||
*/
|
||||
export const reactiveUnlockVaultGuard: CanActivateFn = () => {
|
||||
const router = inject(Router);
|
||||
const authService = inject(AuthService);
|
||||
const accountService = inject(AccountService);
|
||||
const desktopSettingsService = inject(DesktopSettingsService);
|
||||
|
||||
return combineLatest([accountService.activeAccount$, desktopSettingsService.modalMode$]).pipe(
|
||||
switchMap(([account, modalMode]) => {
|
||||
if (!account) {
|
||||
return [true];
|
||||
}
|
||||
|
||||
// Monitor when the vault has been unlocked.
|
||||
return authService.authStatusFor$(account.id).pipe(
|
||||
distinctUntilChanged(),
|
||||
map((authStatus) => {
|
||||
// If vault is unlocked and we're not in modal mode, redirect to vault
|
||||
if (authStatus === AuthenticationStatus.Unlocked && !modalMode?.isModalModeActive) {
|
||||
return router.createUrlTree(["/vault"]);
|
||||
}
|
||||
|
||||
// Otherwise keep user on the lock screen
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
<div class="tw-flex tw-flex-col tw-h-full tw-bg-background-alt">
|
||||
<bit-section
|
||||
disableMargin
|
||||
class="tw-sticky tw-top-0 tw-z-10 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
|
||||
>
|
||||
<bit-section-header class="tw-app-region-drag tw-bg-background">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
|
||||
|
||||
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
|
||||
{{ "savePasskeyQuestion" | i18n }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
slot="end"
|
||||
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
|
||||
(click)="closeModal()"
|
||||
[label]="'close' | i18n"
|
||||
>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</bit-section-header>
|
||||
</bit-section>
|
||||
|
||||
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col">
|
||||
<div *ngIf="(ciphers$ | async)?.length === 0; else hasCiphers">
|
||||
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
|
||||
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
{{ "noMatchingLoginsForSite" | i18n }}
|
||||
</div>
|
||||
<button bitButton type="button" buttonType="primary" (click)="confirmPasskey()">
|
||||
{{ "savePasskeyNewLogin" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #hasCiphers>
|
||||
<bit-item *ngFor="let c of ciphers$ | async" class="">
|
||||
<button type="button" bit-item-content (click)="addCredentialToCipher(c)">
|
||||
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
|
||||
<button bitLink [title]="c.name" type="button">
|
||||
{{ c.name }}
|
||||
</button>
|
||||
<span slot="secondary">{{ c.subTitle }}</span>
|
||||
<span bitBadge slot="end">{{ "save" | i18n }}</span>
|
||||
</button>
|
||||
</bit-item>
|
||||
<bit-item class="">
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
type="button"
|
||||
bit-item-content
|
||||
(click)="confirmPasskey()"
|
||||
>
|
||||
<a bitLink linkType="primary" class="tw-font-medium tw-text-base">
|
||||
{{ "saveNewPasskey" | i18n }}
|
||||
</a>
|
||||
</button>
|
||||
</bit-item>
|
||||
</ng-template>
|
||||
</bit-section>
|
||||
</div>
|
||||
@@ -0,0 +1,238 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
import { Fido2CreateComponent } from "./fido2-create.component";
|
||||
|
||||
describe("Fido2CreateComponent", () => {
|
||||
let component: Fido2CreateComponent;
|
||||
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
|
||||
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
|
||||
let mockAccountService: MockProxy<AccountService>;
|
||||
let mockCipherService: MockProxy<CipherService>;
|
||||
let mockDesktopAutofillService: MockProxy<DesktopAutofillService>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockDomainSettingsService: MockProxy<DomainSettingsService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockPasswordRepromptService: MockProxy<PasswordRepromptService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>({
|
||||
id: "test-user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDesktopSettingsService = mock<DesktopSettingsService>();
|
||||
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
|
||||
mockAccountService = mock<AccountService>();
|
||||
mockCipherService = mock<CipherService>();
|
||||
mockDesktopAutofillService = mock<DesktopAutofillService>();
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockDomainSettingsService = mock<DomainSettingsService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockPasswordRepromptService = mock<PasswordRepromptService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockSession = mock<DesktopFido2UserInterfaceSession>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
|
||||
mockAccountService.activeAccount$ = activeAccountSubject;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
Fido2CreateComponent,
|
||||
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
|
||||
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: CipherService, useValue: mockCipherService },
|
||||
{ provide: DesktopAutofillService, useValue: mockDesktopAutofillService },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{ provide: DomainSettingsService, useValue: mockDomainSettingsService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
component = TestBed.inject(Fido2CreateComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createMockCiphers(): CipherView[] {
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = "cipher-1";
|
||||
cipher1.name = "Test Cipher 1";
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = {
|
||||
username: "test1@example.com",
|
||||
uris: [{ uri: "https://example.com", match: null }],
|
||||
matchesUri: jest.fn().mockReturnValue(true),
|
||||
get hasFido2Credentials() {
|
||||
return false;
|
||||
},
|
||||
} as any;
|
||||
cipher1.reprompt = CipherRepromptType.None;
|
||||
cipher1.deletedDate = null;
|
||||
|
||||
return [cipher1];
|
||||
}
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
beforeEach(() => {
|
||||
mockSession.getRpId.mockResolvedValue("example.com");
|
||||
Object.defineProperty(mockDesktopAutofillService, "lastRegistrationRequest", {
|
||||
get: jest.fn().mockReturnValue({
|
||||
userHandle: new Uint8Array([1, 2, 3]),
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
mockDomainSettingsService.getUrlEquivalentDomains.mockReturnValue(of(new Set<string>()));
|
||||
});
|
||||
|
||||
it("should initialize session and set show header to false", async () => {
|
||||
const mockCiphers = createMockCiphers();
|
||||
mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
|
||||
expect(component.session).toBe(mockSession);
|
||||
});
|
||||
|
||||
it("should show error dialog when no active session found", async () => {
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "unableToSavePasskey" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
acceptAction: expect.any(Function),
|
||||
cancelButtonText: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addCredentialToCipher", () => {
|
||||
beforeEach(() => {
|
||||
component.session = mockSession;
|
||||
});
|
||||
|
||||
it("should add passkey to cipher", async () => {
|
||||
const cipher = createMockCiphers()[0];
|
||||
|
||||
await component.addCredentialToCipher(cipher);
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
|
||||
});
|
||||
|
||||
it("should not add passkey when password reprompt is cancelled", async () => {
|
||||
const cipher = createMockCiphers()[0];
|
||||
cipher.reprompt = CipherRepromptType.Password;
|
||||
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
|
||||
|
||||
await component.addCredentialToCipher(cipher);
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
|
||||
});
|
||||
|
||||
it("should call openSimpleDialog when cipher already has a fido2 credential", async () => {
|
||||
const cipher = createMockCiphers()[0];
|
||||
Object.defineProperty(cipher.login, "hasFido2Credentials", {
|
||||
get: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
await component.addCredentialToCipher(cipher);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "overwritePasskey" },
|
||||
content: { key: "alreadyContainsPasskey" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
|
||||
});
|
||||
|
||||
it("should not add passkey when user cancels overwrite dialog", async () => {
|
||||
const cipher = createMockCiphers()[0];
|
||||
Object.defineProperty(cipher.login, "hasFido2Credentials", {
|
||||
get: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await component.addCredentialToCipher(cipher);
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmPasskey", () => {
|
||||
beforeEach(() => {
|
||||
component.session = mockSession;
|
||||
});
|
||||
|
||||
it("should confirm passkey creation successfully", async () => {
|
||||
await component.confirmPasskey();
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should call openSimpleDialog when session is null", async () => {
|
||||
component.session = null;
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await component.confirmPasskey();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "unableToSavePasskey" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
acceptAction: expect.any(Function),
|
||||
cancelButtonText: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeModal", () => {
|
||||
it("should close modal and notify session", async () => {
|
||||
component.session = mockSession;
|
||||
|
||||
await component.closeModal();
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { RouterModule, Router } from "@angular/router";
|
||||
import { combineLatest, map, Observable, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
DialogService,
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
IconModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
TableModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
SimpleDialogOptions,
|
||||
} from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
TableModule,
|
||||
JslibModule,
|
||||
IconModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
SectionComponent,
|
||||
ItemModule,
|
||||
BadgeModule,
|
||||
],
|
||||
templateUrl: "fido2-create.component.html",
|
||||
})
|
||||
export class Fido2CreateComponent implements OnInit, OnDestroy {
|
||||
session?: DesktopFido2UserInterfaceSession = null;
|
||||
ciphers$: Observable<CipherView[]>;
|
||||
private destroy$ = new Subject<void>();
|
||||
readonly Icons = { BitwardenShield, NoResults };
|
||||
|
||||
private get DIALOG_MESSAGES() {
|
||||
return {
|
||||
unexpectedErrorShort: {
|
||||
title: { key: "unexpectedErrorShort" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
cancelButtonText: null as null,
|
||||
acceptAction: async () => this.dialogService.closeAll(),
|
||||
},
|
||||
unableToSavePasskey: {
|
||||
title: { key: "unableToSavePasskey" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
cancelButtonText: null as null,
|
||||
acceptAction: async () => this.dialogService.closeAll(),
|
||||
},
|
||||
overwritePasskey: {
|
||||
title: { key: "overwritePasskey" },
|
||||
content: { key: "alreadyContainsPasskey" },
|
||||
type: "warning",
|
||||
},
|
||||
} as const satisfies Record<string, SimpleDialogOptions>;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly desktopSettingsService: DesktopSettingsService,
|
||||
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly cipherService: CipherService,
|
||||
private readonly desktopAutofillService: DesktopAutofillService,
|
||||
private readonly dialogService: DialogService,
|
||||
private readonly domainSettingsService: DomainSettingsService,
|
||||
private readonly passwordRepromptService: PasswordRepromptService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.session = this.fido2UserInterfaceService.getCurrentSession();
|
||||
|
||||
if (this.session) {
|
||||
const rpid = await this.session.getRpId();
|
||||
this.initializeCiphersObservable(rpid);
|
||||
} else {
|
||||
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async addCredentialToCipher(cipher: CipherView): Promise<void> {
|
||||
const isConfirmed = await this.validateCipherAccess(cipher);
|
||||
|
||||
try {
|
||||
if (!this.session) {
|
||||
throw new Error("Missing session");
|
||||
}
|
||||
|
||||
this.session.notifyConfirmCreateCredential(isConfirmed, cipher);
|
||||
} catch {
|
||||
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async confirmPasskey(): Promise<void> {
|
||||
try {
|
||||
if (!this.session) {
|
||||
throw new Error("Missing session");
|
||||
}
|
||||
|
||||
this.session.notifyConfirmCreateCredential(true);
|
||||
} catch {
|
||||
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
|
||||
}
|
||||
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async closeModal(): Promise<void> {
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
|
||||
if (this.session) {
|
||||
this.session.notifyConfirmCreateCredential(false);
|
||||
this.session.confirmChosenCipher(null);
|
||||
}
|
||||
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
private initializeCiphersObservable(rpid: string): void {
|
||||
const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest;
|
||||
|
||||
if (!lastRegistrationRequest || !rpid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userHandle = Fido2Utils.bufferToString(
|
||||
new Uint8Array(lastRegistrationRequest.userHandle),
|
||||
);
|
||||
|
||||
this.ciphers$ = combineLatest([
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
this.domainSettingsService.getUrlEquivalentDomains(rpid),
|
||||
]).pipe(
|
||||
switchMap(async ([activeUserId, equivalentDomains]) => {
|
||||
if (!activeUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const allCiphers = await this.cipherService.getAllDecrypted(activeUserId);
|
||||
return allCiphers.filter(
|
||||
(cipher) =>
|
||||
cipher != null &&
|
||||
cipher.type == CipherType.Login &&
|
||||
cipher.login?.matchesUri(rpid, equivalentDomains) &&
|
||||
Fido2Utils.cipherHasNoOtherPasskeys(cipher, userHandle) &&
|
||||
!cipher.deletedDate,
|
||||
);
|
||||
} catch {
|
||||
await this.showErrorDialog(this.DIALOG_MESSAGES.unexpectedErrorShort);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
|
||||
if (cipher.login.hasFido2Credentials) {
|
||||
const overwriteConfirmed = await this.dialogService.openSimpleDialog(
|
||||
this.DIALOG_MESSAGES.overwritePasskey,
|
||||
);
|
||||
|
||||
if (!overwriteConfirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (cipher.reprompt) {
|
||||
return this.passwordRepromptService.showPasswordPrompt();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async showErrorDialog(config: SimpleDialogOptions): Promise<void> {
|
||||
await this.dialogService.openSimpleDialog(config);
|
||||
await this.closeModal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="tw-flex tw-flex-col tw-h-full">
|
||||
<bit-section
|
||||
disableMargin
|
||||
class="tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
|
||||
>
|
||||
<bit-section-header class="tw-app-region-drag tw-bg-background">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
|
||||
|
||||
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
|
||||
{{ "savePasskeyQuestion" | i18n }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
slot="end"
|
||||
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
|
||||
(click)="closeModal()"
|
||||
[label]="'close' | i18n"
|
||||
>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</bit-section-header>
|
||||
</bit-section>
|
||||
|
||||
<div class="tw-h-full tw-items-start">
|
||||
<bit-section
|
||||
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-start tw-items-center tw-gap-2 tw-h-full tw-px-5"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
|
||||
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
<b>{{ "passkeyAlreadyExists" | i18n }}</b>
|
||||
{{ "applicationDoesNotSupportDuplicates" | i18n }}
|
||||
</div>
|
||||
<button bitButton type="button" buttonType="primary" (click)="closeModal()">
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
import { Fido2ExcludedCiphersComponent } from "./fido2-excluded-ciphers.component";
|
||||
|
||||
describe("Fido2ExcludedCiphersComponent", () => {
|
||||
let component: Fido2ExcludedCiphersComponent;
|
||||
let fixture: ComponentFixture<Fido2ExcludedCiphersComponent>;
|
||||
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
|
||||
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
|
||||
let mockAccountService: MockProxy<AccountService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDesktopSettingsService = mock<DesktopSettingsService>();
|
||||
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
|
||||
mockAccountService = mock<AccountService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockSession = mock<DesktopFido2UserInterfaceSession>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Fido2ExcludedCiphersComponent],
|
||||
providers: [
|
||||
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
|
||||
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Fido2ExcludedCiphersComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should initialize session", async () => {
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
|
||||
expect(component.session).toBe(mockSession);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeModal", () => {
|
||||
it("should close modal and notify session when session exists", async () => {
|
||||
component.session = mockSession;
|
||||
|
||||
await component.closeModal();
|
||||
|
||||
expect(mockDesktopSettingsService.setModalMode).toHaveBeenCalledWith(false);
|
||||
expect(mockAccountService.setShowHeader).toHaveBeenCalledWith(true);
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { RouterModule, Router } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
IconModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
TableModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
TableModule,
|
||||
JslibModule,
|
||||
IconModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
SectionComponent,
|
||||
ItemModule,
|
||||
BadgeModule,
|
||||
],
|
||||
templateUrl: "fido2-excluded-ciphers.component.html",
|
||||
})
|
||||
export class Fido2ExcludedCiphersComponent implements OnInit, OnDestroy {
|
||||
session?: DesktopFido2UserInterfaceSession = null;
|
||||
readonly Icons = { BitwardenShield, NoResults };
|
||||
|
||||
constructor(
|
||||
private readonly desktopSettingsService: DesktopSettingsService,
|
||||
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.session = this.fido2UserInterfaceService.getCurrentSession();
|
||||
}
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async closeModal(): Promise<void> {
|
||||
// Clean up modal state
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
|
||||
// Clean up session state
|
||||
if (this.session) {
|
||||
this.session.notifyConfirmCreateCredential(false);
|
||||
this.session.confirmChosenCipher(null);
|
||||
}
|
||||
|
||||
// Navigate away
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="tw-flex tw-flex-col tw-h-full">
|
||||
<bit-section
|
||||
disableMargin
|
||||
class="tw-sticky tw-top-0 tw-z-10 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
|
||||
>
|
||||
<bit-section-header class="tw-app-region-drag tw-bg-background">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
|
||||
|
||||
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">{{ "passkeyLogin" | i18n }}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
slot="end"
|
||||
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
|
||||
(click)="closeModal()"
|
||||
[label]="'close' | i18n"
|
||||
>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</bit-section-header>
|
||||
</bit-section>
|
||||
|
||||
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col tw-grow">
|
||||
<bit-item *ngFor="let c of ciphers$ | async" class="">
|
||||
<button type="button" bit-item-content (click)="chooseCipher(c)">
|
||||
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
|
||||
<button bitLink [title]="c.name" type="button">
|
||||
{{ c.name }}
|
||||
</button>
|
||||
<span slot="secondary">{{ c.subTitle }}</span>
|
||||
<span bitBadge slot="end">{{ "select" | i18n }}</span>
|
||||
</button>
|
||||
</bit-item>
|
||||
</bit-section>
|
||||
</div>
|
||||
@@ -0,0 +1,196 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
import { Fido2VaultComponent } from "./fido2-vault.component";
|
||||
|
||||
describe("Fido2VaultComponent", () => {
|
||||
let component: Fido2VaultComponent;
|
||||
let fixture: ComponentFixture<Fido2VaultComponent>;
|
||||
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
|
||||
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
|
||||
let mockCipherService: MockProxy<CipherService>;
|
||||
let mockAccountService: MockProxy<AccountService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockPasswordRepromptService: MockProxy<PasswordRepromptService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
|
||||
const mockActiveAccount = { id: "test-user-id", email: "test@example.com" };
|
||||
const mockCipherIds = ["cipher-1", "cipher-2", "cipher-3"];
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDesktopSettingsService = mock<DesktopSettingsService>();
|
||||
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
|
||||
mockCipherService = mock<CipherService>();
|
||||
mockAccountService = mock<AccountService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockPasswordRepromptService = mock<PasswordRepromptService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockSession = mock<DesktopFido2UserInterfaceSession>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
|
||||
mockAccountService.activeAccount$ = of(mockActiveAccount as Account);
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
|
||||
mockSession.availableCipherIds$ = of(mockCipherIds);
|
||||
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Fido2VaultComponent],
|
||||
providers: [
|
||||
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
|
||||
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
|
||||
{ provide: CipherService, useValue: mockCipherService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Fido2VaultComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
const mockCiphers: any[] = [
|
||||
{
|
||||
id: "cipher-1",
|
||||
name: "Test Cipher 1",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "test1@example.com",
|
||||
},
|
||||
reprompt: CipherRepromptType.None,
|
||||
deletedDate: null,
|
||||
},
|
||||
{
|
||||
id: "cipher-2",
|
||||
name: "Test Cipher 2",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "test2@example.com",
|
||||
},
|
||||
reprompt: CipherRepromptType.None,
|
||||
deletedDate: null,
|
||||
},
|
||||
{
|
||||
id: "cipher-3",
|
||||
name: "Test Cipher 3",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "test3@example.com",
|
||||
},
|
||||
reprompt: CipherRepromptType.Password,
|
||||
deletedDate: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should initialize session and load ciphers successfully", async () => {
|
||||
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(mockCiphers));
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
|
||||
expect(component.session).toBe(mockSession);
|
||||
expect(component.cipherIds$).toBe(mockSession.availableCipherIds$);
|
||||
expect(mockCipherService.cipherListViews$).toHaveBeenCalledWith(mockActiveAccount.id);
|
||||
});
|
||||
|
||||
it("should handle when no active session found", async () => {
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.session).toBeNull();
|
||||
});
|
||||
|
||||
it("should filter out deleted ciphers", async () => {
|
||||
const ciphersWithDeleted = [
|
||||
...mockCiphers.slice(0, 1),
|
||||
{ ...mockCiphers[1], deletedDate: new Date() },
|
||||
...mockCiphers.slice(2),
|
||||
];
|
||||
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(ciphersWithDeleted));
|
||||
|
||||
await component.ngOnInit();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
let ciphersResult: CipherView[] = [];
|
||||
component.ciphers$.subscribe((ciphers) => {
|
||||
ciphersResult = ciphers;
|
||||
});
|
||||
|
||||
expect(ciphersResult).toHaveLength(2);
|
||||
expect(ciphersResult.every((cipher) => !cipher.deletedDate)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chooseCipher", () => {
|
||||
const cipher = mockCiphers[0];
|
||||
|
||||
beforeEach(() => {
|
||||
component.session = mockSession;
|
||||
});
|
||||
|
||||
it("should choose cipher when access is validated", async () => {
|
||||
cipher.reprompt = CipherRepromptType.None;
|
||||
|
||||
await component.chooseCipher(cipher);
|
||||
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
|
||||
});
|
||||
|
||||
it("should prompt for password when cipher requires reprompt", async () => {
|
||||
cipher.reprompt = CipherRepromptType.Password;
|
||||
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(true);
|
||||
|
||||
await component.chooseCipher(cipher);
|
||||
|
||||
expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled();
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true);
|
||||
});
|
||||
|
||||
it("should not choose cipher when password reprompt is cancelled", async () => {
|
||||
cipher.reprompt = CipherRepromptType.Password;
|
||||
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
|
||||
|
||||
await component.chooseCipher(cipher);
|
||||
|
||||
expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled();
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeModal", () => {
|
||||
it("should close modal and notify session", async () => {
|
||||
component.session = mockSession;
|
||||
|
||||
await component.closeModal();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { RouterModule, Router } from "@angular/router";
|
||||
import {
|
||||
firstValueFrom,
|
||||
map,
|
||||
combineLatest,
|
||||
of,
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
Subject,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { BitwardenShield } from "@bitwarden/assets/svg";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
IconModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
TableModule,
|
||||
BitIconButtonComponent,
|
||||
SectionHeaderComponent,
|
||||
} from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
TableModule,
|
||||
JslibModule,
|
||||
IconModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
SectionComponent,
|
||||
ItemModule,
|
||||
BadgeModule,
|
||||
],
|
||||
templateUrl: "fido2-vault.component.html",
|
||||
})
|
||||
export class Fido2VaultComponent implements OnInit, OnDestroy {
|
||||
session?: DesktopFido2UserInterfaceSession = null;
|
||||
private destroy$ = new Subject<void>();
|
||||
private ciphersSubject = new BehaviorSubject<CipherView[]>([]);
|
||||
ciphers$: Observable<CipherView[]> = this.ciphersSubject.asObservable();
|
||||
cipherIds$: Observable<string[]> | undefined;
|
||||
readonly Icons = { BitwardenShield };
|
||||
|
||||
constructor(
|
||||
private readonly desktopSettingsService: DesktopSettingsService,
|
||||
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
|
||||
private readonly cipherService: CipherService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dialogService: DialogService,
|
||||
private readonly logService: LogService,
|
||||
private readonly passwordRepromptService: PasswordRepromptService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.session = this.fido2UserInterfaceService.getCurrentSession();
|
||||
this.cipherIds$ = this.session?.availableCipherIds$;
|
||||
await this.loadCiphers();
|
||||
}
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async chooseCipher(cipher: CipherView): Promise<void> {
|
||||
if (!this.session) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "unexpectedErrorShort" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
cancelButtonText: null,
|
||||
});
|
||||
await this.closeModal();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isConfirmed = await this.validateCipherAccess(cipher);
|
||||
this.session.confirmChosenCipher(cipher.id, isConfirmed);
|
||||
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async closeModal(): Promise<void> {
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
|
||||
if (this.session) {
|
||||
this.session.notifyConfirmCreateCredential(false);
|
||||
this.session.confirmChosenCipher(null);
|
||||
}
|
||||
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
private async loadCiphers(): Promise<void> {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (!activeUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine cipher list with optional cipher IDs filter
|
||||
combineLatest([this.cipherService.cipherListViews$(activeUserId), this.cipherIds$ || of(null)])
|
||||
.pipe(
|
||||
map(([ciphers, cipherIds]) => {
|
||||
// Filter out deleted ciphers
|
||||
const activeCiphers = ciphers.filter((cipher) => !cipher.deletedDate);
|
||||
|
||||
// If specific IDs provided, filter by them
|
||||
if (cipherIds?.length > 0) {
|
||||
return activeCiphers.filter((cipher) => cipherIds.includes(cipher.id as string));
|
||||
}
|
||||
|
||||
return activeCiphers;
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe({
|
||||
next: (ciphers) => this.ciphersSubject.next(ciphers as CipherView[]),
|
||||
error: (error: unknown) => this.logService.error("Failed to load ciphers", error),
|
||||
});
|
||||
}
|
||||
|
||||
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
|
||||
if (cipher.reprompt !== CipherRepromptType.None) {
|
||||
return this.passwordRepromptService.showPasswordPrompt();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export default {
|
||||
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
|
||||
ipcRenderer.invoke("autofill.runCommand", params),
|
||||
|
||||
listenerReady: () => ipcRenderer.send("autofill.listenerReady"),
|
||||
|
||||
listenPasskeyRegistration: (
|
||||
fn: (
|
||||
clientId: number,
|
||||
@@ -127,6 +129,25 @@ export default {
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
listenNativeStatus: (
|
||||
fn: (clientId: number, sequenceNumber: number, status: { key: string; value: string }) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
"autofill.nativeStatus",
|
||||
(
|
||||
event,
|
||||
data: {
|
||||
clientId: number;
|
||||
sequenceNumber: number;
|
||||
status: { key: string; value: string };
|
||||
},
|
||||
) => {
|
||||
const { clientId, sequenceNumber, status } = data;
|
||||
fn(clientId, sequenceNumber, status);
|
||||
},
|
||||
);
|
||||
},
|
||||
configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => {
|
||||
ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut });
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, OnDestroy } from "@angular/core";
|
||||
import {
|
||||
Subject,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
@@ -8,11 +10,13 @@ import {
|
||||
mergeMap,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
EMPTY,
|
||||
} from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
@@ -43,9 +47,15 @@ import {
|
||||
|
||||
import type { NativeWindowObject } from "./desktop-fido2-user-interface.service";
|
||||
|
||||
const NativeCredentialSyncFeatureFlag =
|
||||
ipc.platform.deviceType === DeviceType.WindowsDesktop
|
||||
? FeatureFlag.WindowsNativeCredentialSync
|
||||
: FeatureFlag.MacOsNativeCredentialSync;
|
||||
|
||||
@Injectable()
|
||||
export class DesktopAutofillService implements OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
private registrationRequest: autofill.PasskeyRegistrationRequest;
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
@@ -53,35 +63,64 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
private configService: ConfigService,
|
||||
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
|
||||
private accountService: AccountService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
|
||||
.getFeatureFlag$(NativeCredentialSyncFeatureFlag)
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((enabled) => {
|
||||
if (!enabled) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
filter((userId): userId is UserId => userId != null),
|
||||
switchMap((userId) => this.cipherService.cipherViews$(userId)),
|
||||
filter((enabled) => enabled === true), // Only proceed if feature is enabled
|
||||
switchMap(() => {
|
||||
return combineLatest([
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
filter((userId): userId is UserId => userId != null),
|
||||
),
|
||||
this.authService.activeAccountStatus$,
|
||||
]).pipe(
|
||||
// Only proceed when the vault is unlocked
|
||||
filter(([, status]) => status === AuthenticationStatus.Unlocked),
|
||||
// Then get cipher views
|
||||
switchMap(([userId]) => this.cipherService.cipherViews$(userId)),
|
||||
);
|
||||
}),
|
||||
// TODO: This will unset all the autofill credentials on the OS
|
||||
// when the account locks. We should instead explicilty clear the credentials
|
||||
// when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead.
|
||||
debounceTime(100), // just a precaution to not spam the sync if there are multiple changes (we typically observe a null change)
|
||||
// No filter for empty arrays here - we want to sync even if there are 0 items
|
||||
filter((cipherViewMap) => cipherViewMap !== null),
|
||||
|
||||
mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Listen for sign out to clear credentials
|
||||
this.authService.activeAccountStatus$
|
||||
.pipe(
|
||||
filter((status) => status === AuthenticationStatus.LoggedOut),
|
||||
mergeMap(() => this.sync([])), // sync an empty array
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.listenIpc();
|
||||
}
|
||||
|
||||
async adHocSync(): Promise<any> {
|
||||
this.logService.debug("Performing AdHoc sync");
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
const userId = account?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("No active user found");
|
||||
}
|
||||
|
||||
const cipherViewMap = await firstValueFrom(this.cipherService.cipherViews$(userId));
|
||||
this.logService.info("Performing AdHoc sync", Object.values(cipherViewMap ?? []));
|
||||
await this.sync(Object.values(cipherViewMap ?? []));
|
||||
}
|
||||
|
||||
/** Give metadata about all available credentials in the users vault */
|
||||
async sync(cipherViews: CipherView[]) {
|
||||
const status = await this.status();
|
||||
@@ -94,8 +133,8 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
let fido2Credentials: NativeAutofillFido2Credential[];
|
||||
let passwordCredentials: NativeAutofillPasswordCredential[];
|
||||
let fido2Credentials: NativeAutofillFido2Credential[] = [];
|
||||
let passwordCredentials: NativeAutofillPasswordCredential[] = [];
|
||||
|
||||
if (status.value.support.password) {
|
||||
passwordCredentials = cipherViews
|
||||
@@ -122,6 +161,11 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
}));
|
||||
}
|
||||
|
||||
this.logService.info("Syncing autofill credentials", {
|
||||
fido2Credentials,
|
||||
passwordCredentials,
|
||||
});
|
||||
|
||||
const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({
|
||||
namespace: "autofill",
|
||||
command: "sync",
|
||||
@@ -147,107 +191,155 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
get lastRegistrationRequest() {
|
||||
return this.registrationRequest;
|
||||
}
|
||||
|
||||
listenIpc() {
|
||||
ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => {
|
||||
this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request);
|
||||
this.logService.warning(
|
||||
"listenPasskeyRegistration2",
|
||||
this.convertRegistrationRequest(request),
|
||||
);
|
||||
this.logService.debug("Setting up Native -> Electron IPC Handlers");
|
||||
ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => {
|
||||
if (!(await this.configService.getFeatureFlag(NativeCredentialSyncFeatureFlag))) {
|
||||
this.logService.debug(
|
||||
`listenPasskeyRegistration: ${NativeCredentialSyncFeatureFlag} feature flag is disabled`,
|
||||
);
|
||||
callback(new Error(`${NativeCredentialSyncFeatureFlag} feature flag is disabled`), null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.registrationRequest = request;
|
||||
|
||||
this.logService.debug("listenPasskeyRegistration", clientId, sequenceNumber, request);
|
||||
this.logService.debug("listenPasskeyRegistration2", this.convertRegistrationRequest(request));
|
||||
|
||||
const controller = new AbortController();
|
||||
void this.fido2AuthenticatorService
|
||||
.makeCredential(
|
||||
|
||||
try {
|
||||
const response = await this.fido2AuthenticatorService.makeCredential(
|
||||
this.convertRegistrationRequest(request),
|
||||
{ windowXy: request.windowXy },
|
||||
controller,
|
||||
)
|
||||
.then((response) => {
|
||||
callback(null, this.convertRegistrationResponse(request, response));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logService.error("listenPasskeyRegistration error", error);
|
||||
callback(error, null);
|
||||
});
|
||||
);
|
||||
|
||||
this.logService.debug("Sending registration response to plugin via callback");
|
||||
callback(null, this.convertRegistrationResponse(request, response));
|
||||
} catch (error) {
|
||||
this.logService.error("listenPasskeyRegistration error", error);
|
||||
callback(error, null);
|
||||
}
|
||||
this.logService.info("Passkey registration completed.");
|
||||
});
|
||||
|
||||
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
|
||||
async (clientId, sequenceNumber, request, callback) => {
|
||||
this.logService.warning(
|
||||
if (!(await this.configService.getFeatureFlag(NativeCredentialSyncFeatureFlag))) {
|
||||
this.logService.debug(
|
||||
`listenPasskeyAssertionWithoutUserInterface: ${NativeCredentialSyncFeatureFlag} feature flag is disabled`,
|
||||
);
|
||||
callback(new Error(`${NativeCredentialSyncFeatureFlag} feature flag is disabled`), null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.debug(
|
||||
"listenPasskeyAssertion without user interface",
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request,
|
||||
);
|
||||
|
||||
// For some reason the credentialId is passed as an empty array in the request, so we need to
|
||||
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
|
||||
if (request.recordIdentifier && request.credentialId.length === 0) {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (!activeUserId) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Active user not found");
|
||||
callback(new Error("Active user not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
|
||||
if (!cipher) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
|
||||
callback(new Error("Cipher not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const decrypted = await this.cipherService.decrypt(cipher, activeUserId);
|
||||
|
||||
const fido2Credential = decrypted.login.fido2Credentials?.[0];
|
||||
if (!fido2Credential) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
|
||||
callback(new Error("Fido2Credential not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
request.credentialId = Array.from(
|
||||
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
void this.fido2AuthenticatorService
|
||||
.getAssertion(
|
||||
this.convertAssertionRequest(request),
|
||||
|
||||
try {
|
||||
// For some reason the credentialId is passed as an empty array in the request, so we need to
|
||||
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
|
||||
if (request.recordIdentifier && request.credentialId.length === 0) {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (!activeUserId) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Active user not found");
|
||||
callback(new Error("Active user not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
|
||||
if (!cipher) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
|
||||
callback(new Error("Cipher not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const decrypted = await this.cipherService.decrypt(cipher, activeUserId);
|
||||
|
||||
const fido2Credential = decrypted.login.fido2Credentials?.[0];
|
||||
if (!fido2Credential) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
|
||||
callback(new Error("Fido2Credential not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
request.credentialId = Array.from(
|
||||
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.fido2AuthenticatorService.getAssertion(
|
||||
this.convertAssertionRequest(request, true),
|
||||
{ windowXy: request.windowXy },
|
||||
controller,
|
||||
)
|
||||
.then((response) => {
|
||||
callback(null, this.convertAssertionResponse(request, response));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logService.error("listenPasskeyAssertion error", error);
|
||||
callback(error, null);
|
||||
});
|
||||
);
|
||||
|
||||
callback(null, this.convertAssertionResponse(request, response));
|
||||
} catch (error) {
|
||||
this.logService.error("listenPasskeyAssertion error", error);
|
||||
callback(error, null);
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
|
||||
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
|
||||
if (!(await this.configService.getFeatureFlag(NativeCredentialSyncFeatureFlag))) {
|
||||
this.logService.debug(
|
||||
`listenPasskeyAssertion: ${NativeCredentialSyncFeatureFlag} feature flag is disabled`,
|
||||
);
|
||||
callback(new Error(`${NativeCredentialSyncFeatureFlag} feature flag is disabled`), null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.debug("listenPasskeyAssertion", clientId, sequenceNumber, request);
|
||||
|
||||
const controller = new AbortController();
|
||||
void this.fido2AuthenticatorService
|
||||
.getAssertion(
|
||||
try {
|
||||
const response = await this.fido2AuthenticatorService.getAssertion(
|
||||
this.convertAssertionRequest(request),
|
||||
{ windowXy: request.windowXy },
|
||||
controller,
|
||||
)
|
||||
.then((response) => {
|
||||
callback(null, this.convertAssertionResponse(request, response));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logService.error("listenPasskeyAssertion error", error);
|
||||
callback(error, null);
|
||||
});
|
||||
);
|
||||
|
||||
callback(null, this.convertAssertionResponse(request, response));
|
||||
} catch (error) {
|
||||
this.logService.error("listenPasskeyAssertion error", error);
|
||||
callback(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for native status messages
|
||||
ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => {
|
||||
if (!(await this.configService.getFeatureFlag(NativeCredentialSyncFeatureFlag))) {
|
||||
this.logService.debug(
|
||||
`listenNativeStatus: ${NativeCredentialSyncFeatureFlag} feature flag is disabled`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.info("Received native status", status.key, status.value);
|
||||
if (status.key === "request-sync") {
|
||||
// perform ad-hoc sync
|
||||
await this.adHocSync();
|
||||
}
|
||||
});
|
||||
|
||||
ipc.autofill.listenerReady();
|
||||
}
|
||||
|
||||
private convertRegistrationRequest(
|
||||
@@ -269,7 +361,10 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
alg,
|
||||
type: "public-key",
|
||||
})),
|
||||
excludeCredentialDescriptorList: [],
|
||||
excludeCredentialDescriptorList: request.excludedCredentials.map((credentialId) => ({
|
||||
id: new Uint8Array(credentialId),
|
||||
type: "public-key" as const,
|
||||
})),
|
||||
requireResidentKey: true,
|
||||
requireUserVerification:
|
||||
request.userVerification === "required" || request.userVerification === "preferred",
|
||||
@@ -301,18 +396,19 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
request:
|
||||
| autofill.PasskeyAssertionRequest
|
||||
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
assumeUserPresence: boolean = false,
|
||||
): Fido2AuthenticatorGetAssertionParams {
|
||||
let allowedCredentials;
|
||||
if ("credentialId" in request) {
|
||||
allowedCredentials = [
|
||||
{
|
||||
id: new Uint8Array(request.credentialId),
|
||||
id: new Uint8Array(request.credentialId).buffer,
|
||||
type: "public-key" as const,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
allowedCredentials = request.allowedCredentials.map((credentialId) => ({
|
||||
id: new Uint8Array(credentialId),
|
||||
id: new Uint8Array(credentialId).buffer,
|
||||
type: "public-key" as const,
|
||||
}));
|
||||
}
|
||||
@@ -325,7 +421,7 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
requireUserVerification:
|
||||
request.userVerification === "required" || request.userVerification === "preferred",
|
||||
fallbackSupported: false,
|
||||
assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues
|
||||
assumeUserPresence,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export class DesktopFido2UserInterfaceService
|
||||
nativeWindowObject: NativeWindowObject,
|
||||
abortController?: AbortController,
|
||||
): Promise<DesktopFido2UserInterfaceSession> {
|
||||
this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject);
|
||||
this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject);
|
||||
const session = new DesktopFido2UserInterfaceSession(
|
||||
this.authService,
|
||||
this.cipherService,
|
||||
@@ -94,9 +94,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
) {}
|
||||
|
||||
private confirmCredentialSubject = new Subject<boolean>();
|
||||
private createdCipher: Cipher;
|
||||
|
||||
private availableCipherIdsSubject = new BehaviorSubject<string[]>(null);
|
||||
private updatedCipher: CipherView;
|
||||
|
||||
private rpId = new BehaviorSubject<string>(null);
|
||||
private availableCipherIdsSubject = new BehaviorSubject<string[]>([""]);
|
||||
/**
|
||||
* Observable that emits available cipher IDs once they're confirmed by the UI
|
||||
*/
|
||||
@@ -114,7 +116,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
assumeUserPresence,
|
||||
masterPasswordRepromptRequired,
|
||||
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||
this.logService.warning("pickCredential desktop function", {
|
||||
this.logService.debug("pickCredential desktop function", {
|
||||
cipherIds,
|
||||
userVerification,
|
||||
assumeUserPresence,
|
||||
@@ -123,6 +125,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
|
||||
try {
|
||||
// Check if we can return the credential without user interaction
|
||||
await this.accountService.setShowHeader(false);
|
||||
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) {
|
||||
this.logService.debug(
|
||||
"shortcut - Assuming user presence and returning cipherId",
|
||||
@@ -136,22 +139,27 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
// make the cipherIds available to the UI.
|
||||
this.availableCipherIdsSubject.next(cipherIds);
|
||||
|
||||
await this.showUi("/passkeys", this.windowObject.windowXy);
|
||||
await this.showUi("/fido2-assertion", this.windowObject.windowXy, false);
|
||||
|
||||
const chosenCipherResponse = await this.waitForUiChosenCipher();
|
||||
|
||||
this.logService.debug("Received chosen cipher", chosenCipherResponse);
|
||||
|
||||
return {
|
||||
cipherId: chosenCipherResponse.cipherId,
|
||||
userVerified: chosenCipherResponse.userVerified,
|
||||
cipherId: chosenCipherResponse?.cipherId,
|
||||
userVerified: chosenCipherResponse?.userVerified,
|
||||
};
|
||||
} finally {
|
||||
// Make sure to clean up so the app is never stuck in modal mode?
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
}
|
||||
}
|
||||
|
||||
async getRpId(): Promise<string> {
|
||||
return firstValueFrom(this.rpId.pipe(filter((id) => id != null)));
|
||||
}
|
||||
|
||||
confirmChosenCipher(cipherId: string, userVerified: boolean = false): void {
|
||||
this.chosenCipherSubject.next({ cipherId, userVerified });
|
||||
this.chosenCipherSubject.complete();
|
||||
@@ -159,7 +167,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
|
||||
private async waitForUiChosenCipher(
|
||||
timeoutMs: number = 60000,
|
||||
): Promise<{ cipherId: string; userVerified: boolean } | undefined> {
|
||||
): Promise<{ cipherId?: string; userVerified: boolean } | undefined> {
|
||||
try {
|
||||
return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs)));
|
||||
} catch {
|
||||
@@ -174,7 +182,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
/**
|
||||
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
|
||||
*/
|
||||
notifyConfirmNewCredential(confirmed: boolean): void {
|
||||
notifyConfirmCreateCredential(confirmed: boolean, updatedCipher?: CipherView): void {
|
||||
if (updatedCipher) {
|
||||
this.updatedCipher = updatedCipher;
|
||||
}
|
||||
this.confirmCredentialSubject.next(confirmed);
|
||||
this.confirmCredentialSubject.complete();
|
||||
}
|
||||
@@ -195,60 +206,79 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
async confirmNewCredential({
|
||||
credentialName,
|
||||
userName,
|
||||
userHandle,
|
||||
userVerification,
|
||||
rpId,
|
||||
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||
this.logService.warning(
|
||||
this.logService.debug(
|
||||
"confirmNewCredential",
|
||||
credentialName,
|
||||
userName,
|
||||
userHandle,
|
||||
userVerification,
|
||||
rpId,
|
||||
);
|
||||
this.rpId.next(rpId);
|
||||
|
||||
try {
|
||||
await this.showUi("/passkeys", this.windowObject.windowXy);
|
||||
await this.showUi("/fido2-creation", this.windowObject.windowXy, false);
|
||||
|
||||
// Wait for the UI to wrap up
|
||||
const confirmation = await this.waitForUiNewCredentialConfirmation();
|
||||
if (!confirmation) {
|
||||
return { cipherId: undefined, userVerified: false };
|
||||
}
|
||||
// Create the credential
|
||||
await this.createCredential({
|
||||
credentialName,
|
||||
userName,
|
||||
rpId,
|
||||
userHandle: "",
|
||||
userVerification,
|
||||
});
|
||||
|
||||
// wait for 10ms to help RXJS catch up(?)
|
||||
// We sometimes get a race condition from this.createCredential not updating cipherService in time
|
||||
//console.log("waiting 10ms..");
|
||||
//await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
//console.log("Just waited 10ms");
|
||||
|
||||
// Return the new cipher (this.createdCipher)
|
||||
return { cipherId: this.createdCipher.id, userVerified: userVerification };
|
||||
if (this.updatedCipher) {
|
||||
await this.updateCredential(this.updatedCipher);
|
||||
return { cipherId: this.updatedCipher.id, userVerified: userVerification };
|
||||
} else {
|
||||
// Create the cipher
|
||||
const createdCipher = await this.createCipher({
|
||||
credentialName,
|
||||
userName,
|
||||
rpId,
|
||||
userHandle,
|
||||
userVerification,
|
||||
});
|
||||
return { cipherId: createdCipher.id, userVerified: userVerification };
|
||||
}
|
||||
} finally {
|
||||
// Make sure to clean up so the app is never stuck in modal mode?
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async showUi(route: string, position?: { x: number; y: number }): Promise<void> {
|
||||
private async hideUi(): Promise<void> {
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
private async showUi(
|
||||
route: string,
|
||||
position?: { x: number; y: number },
|
||||
showTrafficButtons: boolean = false,
|
||||
disableRedirect?: boolean,
|
||||
): Promise<void> {
|
||||
// Load the UI:
|
||||
await this.desktopSettingsService.setModalMode(true, position);
|
||||
await this.router.navigate(["/passkeys"]);
|
||||
await this.desktopSettingsService.setModalMode(true, showTrafficButtons, position);
|
||||
await this.accountService.setShowHeader(showTrafficButtons);
|
||||
await this.router.navigate([
|
||||
route,
|
||||
{
|
||||
"disable-redirect": disableRedirect || null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be called by the UI to create a new credential with user input etc.
|
||||
* Can be called by the UI to create a new cipher with user input etc.
|
||||
* @param param0
|
||||
*/
|
||||
async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
|
||||
async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
|
||||
// Store the passkey on a new cipher to avoid replacing something important
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.name = credentialName;
|
||||
|
||||
@@ -267,32 +297,81 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (!activeUserId) {
|
||||
throw new Error("No active user ID found!");
|
||||
}
|
||||
|
||||
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
const createdCipher = await this.cipherService.createWithServer(encCipher);
|
||||
|
||||
this.createdCipher = createdCipher;
|
||||
try {
|
||||
const createdCipher = await this.cipherService.createWithServer(encCipher);
|
||||
|
||||
return createdCipher;
|
||||
return createdCipher;
|
||||
} catch {
|
||||
throw new Error("Unable to create cipher");
|
||||
}
|
||||
}
|
||||
|
||||
async updateCredential(cipher: CipherView): Promise<void> {
|
||||
this.logService.info("updateCredential");
|
||||
await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map(async (a) => {
|
||||
if (a) {
|
||||
const encCipher = await this.cipherService.encrypt(cipher, a.id);
|
||||
await this.cipherService.updateWithServer(encCipher);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {
|
||||
this.logService.warning("informExcludedCredential", existingCipherIds);
|
||||
this.logService.debug("informExcludedCredential", existingCipherIds);
|
||||
|
||||
// make the cipherIds available to the UI.
|
||||
this.availableCipherIdsSubject.next(existingCipherIds);
|
||||
|
||||
await this.accountService.setShowHeader(false);
|
||||
await this.showUi("/fido2-excluded", this.windowObject.windowXy, false);
|
||||
}
|
||||
|
||||
async ensureUnlockedVault(): Promise<void> {
|
||||
this.logService.warning("ensureUnlockedVault");
|
||||
this.logService.debug("ensureUnlockedVault");
|
||||
|
||||
const status = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
if (status !== AuthenticationStatus.Unlocked) {
|
||||
throw new Error("Vault is not unlocked");
|
||||
await this.showUi("/lock", this.windowObject.windowXy, true, true);
|
||||
|
||||
let status2: AuthenticationStatus;
|
||||
try {
|
||||
status2 = await lastValueFrom(
|
||||
this.authService.activeAccountStatus$.pipe(
|
||||
filter((s) => s === AuthenticationStatus.Unlocked),
|
||||
take(1),
|
||||
timeout(1000 * 60 * 5), // 5 minutes
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logService.warning("Error while waiting for vault to unlock", error);
|
||||
}
|
||||
|
||||
if (status2 === AuthenticationStatus.Unlocked) {
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
if (status2 !== AuthenticationStatus.Unlocked) {
|
||||
await this.hideUi();
|
||||
throw new Error("Vault is not unlocked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async informCredentialNotFound(): Promise<void> {
|
||||
this.logService.warning("informCredentialNotFound");
|
||||
this.logService.debug("informCredentialNotFound");
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.logService.warning("close");
|
||||
this.logService.debug("close");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,6 +905,12 @@
|
||||
"unexpectedError": {
|
||||
"message": "An unexpected error has occurred."
|
||||
},
|
||||
"unexpectedErrorShort": {
|
||||
"message": "Unexpected error"
|
||||
},
|
||||
"closeThisBitwardenWindow": {
|
||||
"message": "Close this Bitwarden window and try again."
|
||||
},
|
||||
"itemInformation": {
|
||||
"message": "Item information"
|
||||
},
|
||||
@@ -3324,7 +3330,7 @@
|
||||
"orgTrustWarning1": {
|
||||
"message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint."
|
||||
},
|
||||
"trustUser":{
|
||||
"trustUser": {
|
||||
"message": "Trust user"
|
||||
},
|
||||
"inputRequired": {
|
||||
@@ -3854,6 +3860,75 @@
|
||||
"fileSavedToDevice": {
|
||||
"message": "File saved to device. Manage from your device downloads."
|
||||
},
|
||||
"importantNotice": {
|
||||
"message": "Important notice"
|
||||
},
|
||||
"setupTwoStepLogin": {
|
||||
"message": "Set up two-step login"
|
||||
},
|
||||
"newDeviceVerificationNoticeContentPage1": {
|
||||
"message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025."
|
||||
},
|
||||
"newDeviceVerificationNoticeContentPage2": {
|
||||
"message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access."
|
||||
},
|
||||
"remindMeLater": {
|
||||
"message": "Remind me later"
|
||||
},
|
||||
"newDeviceVerificationNoticePageOneFormContent": {
|
||||
"message": "Do you have reliable access to your email, $EMAIL$?",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "your_name@email.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"newDeviceVerificationNoticePageOneEmailAccessNo": {
|
||||
"message": "No, I do not"
|
||||
},
|
||||
"newDeviceVerificationNoticePageOneEmailAccessYes": {
|
||||
"message": "Yes, I can reliably access my email"
|
||||
},
|
||||
"turnOnTwoStepLogin": {
|
||||
"message": "Turn on two-step login"
|
||||
},
|
||||
"changeAcctEmail": {
|
||||
"message": "Change account email"
|
||||
},
|
||||
"passkeyLogin": {
|
||||
"message": "Log in with passkey?"
|
||||
},
|
||||
"savePasskeyQuestion": {
|
||||
"message": "Save passkey?"
|
||||
},
|
||||
"saveNewPasskey": {
|
||||
"message": "Save as new login"
|
||||
},
|
||||
"savePasskeyNewLogin": {
|
||||
"message": "Save passkey as new login"
|
||||
},
|
||||
"noMatchingLoginsForSite": {
|
||||
"message": "No matching logins for this site"
|
||||
},
|
||||
"overwritePasskey": {
|
||||
"message": "Overwrite passkey?"
|
||||
},
|
||||
"unableToSavePasskey": {
|
||||
"message": "Unable to save passkey"
|
||||
},
|
||||
"alreadyContainsPasskey": {
|
||||
"message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?"
|
||||
},
|
||||
"passkeyAlreadyExists": {
|
||||
"message": "A passkey already exists for this application."
|
||||
},
|
||||
"applicationDoesNotSupportDuplicates": {
|
||||
"message": "This application does not support duplicates."
|
||||
},
|
||||
"closeThisWindow": {
|
||||
"message": "Close this window"
|
||||
},
|
||||
"allowScreenshots": {
|
||||
"message": "Allow screen capture"
|
||||
},
|
||||
|
||||
@@ -130,6 +130,8 @@ export class Main {
|
||||
}
|
||||
|
||||
this.logService = new ElectronLogMainService(null, app.getPath("userData"));
|
||||
this.logService.info("IS THIS THING ON?");
|
||||
this.logService.debug("IS THIS THING ON? [debug]");
|
||||
|
||||
const storageDefaults: any = {};
|
||||
this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults);
|
||||
@@ -304,7 +306,10 @@ export class Main {
|
||||
new ChromiumImporterService();
|
||||
|
||||
this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain);
|
||||
void this.nativeAutofillMain.init();
|
||||
void app.whenReady().then(async () => {
|
||||
this.logService.debug("ATTEMPTING TO INITIALIZE NATIVE AUTOFILL");
|
||||
await this.nativeAutofillMain.init();
|
||||
});
|
||||
|
||||
this.mainDesktopAutotypeService = new MainDesktopAutotypeService(
|
||||
this.logService,
|
||||
|
||||
@@ -53,9 +53,14 @@ export class TrayMain {
|
||||
},
|
||||
{
|
||||
visible: isDev(),
|
||||
label: "Fake Popup",
|
||||
label: "Fake Popup Select",
|
||||
click: () => this.fakePopup(),
|
||||
},
|
||||
{
|
||||
visible: isDev(),
|
||||
label: "Fake Popup Create",
|
||||
click: () => this.fakePopupCreate(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("exit"),
|
||||
@@ -218,4 +223,8 @@ export class TrayMain {
|
||||
private async fakePopup() {
|
||||
await this.messagingService.send("loadurl", { url: "/passkeys", modal: true });
|
||||
}
|
||||
|
||||
private async fakePopupCreate() {
|
||||
await this.messagingService.send("loadurl", { url: "/create-passkey", modal: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +100,10 @@ export class WindowMain {
|
||||
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
|
||||
// Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode.
|
||||
this.win.hide();
|
||||
} else if (!lastValue.isModalModeActive && newValue.isModalModeActive) {
|
||||
} else if (newValue.isModalModeActive) {
|
||||
// Apply the popup modal styles
|
||||
this.logService.info("Applying popup modal styles", newValue.modalPosition);
|
||||
applyPopupModalStyles(this.win, newValue.modalPosition);
|
||||
applyPopupModalStyles(this.win, newValue.showTrafficButtons, newValue.modalPosition);
|
||||
this.win.show();
|
||||
}
|
||||
}),
|
||||
@@ -272,7 +272,7 @@ export class WindowMain {
|
||||
this.win = new BrowserWindow({
|
||||
width: this.windowStates[mainWindowSizeKey].width,
|
||||
height: this.windowStates[mainWindowSizeKey].height,
|
||||
minWidth: 680,
|
||||
minWidth: 600,
|
||||
minHeight: 500,
|
||||
x: this.windowStates[mainWindowSizeKey].x,
|
||||
y: this.windowStates[mainWindowSizeKey].y,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { autofill } from "@bitwarden/desktop-napi";
|
||||
import { autofill, passkey_authenticator } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { WindowMain } from "../../../main/window.main";
|
||||
|
||||
import { CommandDefinition } from "./command";
|
||||
|
||||
type BufferedMessage = {
|
||||
channel: string;
|
||||
data: any;
|
||||
};
|
||||
|
||||
export type RunCommandParams<C extends CommandDefinition> = {
|
||||
namespace: C["namespace"];
|
||||
command: C["name"];
|
||||
@@ -17,13 +22,56 @@ export type RunCommandResult<C extends CommandDefinition> = C["output"];
|
||||
|
||||
export class NativeAutofillMain {
|
||||
private ipcServer: autofill.IpcServer | null;
|
||||
private messageBuffer: BufferedMessage[] = [];
|
||||
private listenerReady = false;
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private windowMain: WindowMain,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Safely sends a message to the renderer, buffering it if the server isn't ready yet
|
||||
*/
|
||||
private safeSend(channel: string, data: any) {
|
||||
if (this.listenerReady && this.windowMain.win?.webContents) {
|
||||
this.windowMain.win.webContents.send(channel, data);
|
||||
} else {
|
||||
this.messageBuffer.push({ channel, data });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes all buffered messages to the renderer
|
||||
*/
|
||||
private flushMessageBuffer() {
|
||||
if (!this.windowMain.win?.webContents) {
|
||||
this.logService.error("Cannot flush message buffer - window not available");
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.info(`Flushing ${this.messageBuffer.length} buffered messages`);
|
||||
|
||||
for (const { channel, data } of this.messageBuffer) {
|
||||
this.windowMain.win.webContents.send(channel, data);
|
||||
}
|
||||
|
||||
this.messageBuffer = [];
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
passkey_authenticator.register();
|
||||
} catch (err) {
|
||||
this.logService.error("Failed to register windows passkey plugin:", err);
|
||||
return JSON.stringify({
|
||||
type: "error",
|
||||
message: "Failed to register windows passkey plugin",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(
|
||||
"autofill.runCommand",
|
||||
<C extends CommandDefinition>(
|
||||
@@ -43,7 +91,7 @@ export class NativeAutofillMain {
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
return;
|
||||
}
|
||||
this.windowMain.win.webContents.send("autofill.passkeyRegistration", {
|
||||
this.safeSend("autofill.passkeyRegistration", {
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request,
|
||||
@@ -56,7 +104,7 @@ export class NativeAutofillMain {
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
return;
|
||||
}
|
||||
this.windowMain.win.webContents.send("autofill.passkeyAssertion", {
|
||||
this.safeSend("autofill.passkeyAssertion", {
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request,
|
||||
@@ -69,28 +117,49 @@ export class NativeAutofillMain {
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
return;
|
||||
}
|
||||
this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", {
|
||||
this.safeSend("autofill.passkeyAssertionWithoutUserInterface", {
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request,
|
||||
});
|
||||
},
|
||||
// NativeStatusCallback
|
||||
(error, clientId, sequenceNumber, status) => {
|
||||
if (error) {
|
||||
this.logService.error("autofill.IpcServer.nativeStatus", error);
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
return;
|
||||
}
|
||||
this.safeSend("autofill.nativeStatus", {
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
status,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on("autofill.listenerReady", () => {
|
||||
this.listenerReady = true;
|
||||
this.logService.info(
|
||||
`Listener is ready, flushing ${this.messageBuffer.length} buffered messages`,
|
||||
);
|
||||
this.flushMessageBuffer();
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completePasskeyRegistration", (event, data) => {
|
||||
this.logService.warning("autofill.completePasskeyRegistration", data);
|
||||
this.logService.debug("autofill.completePasskeyRegistration", data);
|
||||
const { clientId, sequenceNumber, response } = data;
|
||||
this.ipcServer.completeRegistration(clientId, sequenceNumber, response);
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completePasskeyAssertion", (event, data) => {
|
||||
this.logService.warning("autofill.completePasskeyAssertion", data);
|
||||
this.logService.debug("autofill.completePasskeyAssertion", data);
|
||||
const { clientId, sequenceNumber, response } = data;
|
||||
this.ipcServer.completeAssertion(clientId, sequenceNumber, response);
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completeError", (event, data) => {
|
||||
this.logService.warning("autofill.completeError", data);
|
||||
this.logService.debug("autofill.completeError", data);
|
||||
const { clientId, sequenceNumber, error } = data;
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
});
|
||||
|
||||
@@ -14,5 +14,6 @@ export class WindowState {
|
||||
|
||||
export class ModalModeState {
|
||||
isModalModeActive: boolean;
|
||||
showTrafficButtons?: boolean;
|
||||
modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ import { BrowserWindow } from "electron";
|
||||
import { WindowState } from "./models/domain/window-state";
|
||||
|
||||
// change as needed, however limited by mainwindow minimum size
|
||||
const popupWidth = 680;
|
||||
const popupHeight = 500;
|
||||
const popupWidth = 600;
|
||||
const popupHeight = 600;
|
||||
|
||||
type Position = { x: number; y: number };
|
||||
|
||||
export function applyPopupModalStyles(window: BrowserWindow, position?: Position) {
|
||||
export function applyPopupModalStyles(
|
||||
window: BrowserWindow,
|
||||
showTrafficButtons: boolean = true,
|
||||
position?: Position,
|
||||
) {
|
||||
window.unmaximize();
|
||||
window.setSize(popupWidth, popupHeight);
|
||||
window.setWindowButtonVisibility?.(false);
|
||||
window.setWindowButtonVisibility?.(showTrafficButtons);
|
||||
window.setMenuBarVisibility?.(false);
|
||||
window.setResizable(false);
|
||||
window.setAlwaysOnTop(true);
|
||||
@@ -40,7 +44,7 @@ function positionWindow(window: BrowserWindow, position?: Position) {
|
||||
}
|
||||
|
||||
export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) {
|
||||
window.setMinimumSize(680, 500);
|
||||
window.setMinimumSize(popupWidth, popupHeight);
|
||||
|
||||
// need to guard against null/undefined values
|
||||
|
||||
|
||||
@@ -335,9 +335,14 @@ export class DesktopSettingsService {
|
||||
* Sets the modal mode of the application. Setting this changes the windows-size and other properties.
|
||||
* @param value `true` if the application is in modal mode, `false` if it is not.
|
||||
*/
|
||||
async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) {
|
||||
async setModalMode(
|
||||
value: boolean,
|
||||
showTrafficButtons?: boolean,
|
||||
modalPosition?: { x: number; y: number },
|
||||
) {
|
||||
await this.modalModeState.update(() => ({
|
||||
isModalModeActive: value,
|
||||
showTrafficButtons,
|
||||
modalPosition,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class ElectronLogMainService extends BaseLogService {
|
||||
return;
|
||||
}
|
||||
|
||||
log.transports.file.level = "info";
|
||||
log.transports.file.level = "debug";
|
||||
if (this.logDir != null) {
|
||||
log.transports.file.resolvePathFn = () => path.join(this.logDir, "app.log");
|
||||
}
|
||||
|
||||
8
build.sh
Normal file
8
build.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd apps/desktop/desktop_native/napi
|
||||
npm run build
|
||||
cd ../..
|
||||
npm run build && npm run pack:win
|
||||
cd ../..
|
||||
|
||||
@@ -196,7 +196,7 @@ export default tseslint.config(
|
||||
{
|
||||
// uses negative lookahead to whitelist any class that doesn't start with "tw-"
|
||||
// in other words: classnames that start with tw- must be valid TailwindCSS classes
|
||||
whitelist: ["(?!(tw)\\-).*"],
|
||||
whitelist: ["(?!(tw)\\-).*", "tw-app-region-drag", "tw-app-region-no-drag"],
|
||||
},
|
||||
],
|
||||
"tailwindcss/enforces-negative-arbitrary-values": "error",
|
||||
@@ -348,6 +348,7 @@ export default tseslint.config(
|
||||
"file-selector",
|
||||
"mfaType.*",
|
||||
"filter.*", // Temporary until filters are migrated
|
||||
"tw-app-region*", // Custom utility for native passkey modals
|
||||
"tw-@container",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -37,6 +37,8 @@ export class FakeAccountService implements AccountService {
|
||||
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
showHeaderSubject = new ReplaySubject<boolean>(1);
|
||||
private _activeUserId: UserId;
|
||||
get activeUserId() {
|
||||
return this._activeUserId;
|
||||
@@ -55,6 +57,7 @@ export class FakeAccountService implements AccountService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
showHeader$ = this.showHeaderSubject.asObservable();
|
||||
get nextUpAccount$(): Observable<Account> {
|
||||
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
|
||||
map(([accounts, activeAccount, sortedUserIds]) => {
|
||||
@@ -114,6 +117,10 @@ export class FakeAccountService implements AccountService {
|
||||
this.accountsSubject.next(updated);
|
||||
await this.mock.clean(userId);
|
||||
}
|
||||
|
||||
async setShowHeader(value: boolean): Promise<void> {
|
||||
this.showHeaderSubject.next(value);
|
||||
}
|
||||
}
|
||||
|
||||
const loggedOutInfo: AccountInfo = {
|
||||
|
||||
@@ -47,6 +47,8 @@ export abstract class AccountService {
|
||||
abstract sortedUserIds$: Observable<UserId[]>;
|
||||
/** Next account that is not the current active account */
|
||||
abstract nextUpAccount$: Observable<Account>;
|
||||
/** Observable to display the header */
|
||||
abstract showHeader$: Observable<boolean>;
|
||||
/**
|
||||
* Updates the `accounts$` observable with the new account data.
|
||||
*
|
||||
@@ -100,6 +102,11 @@ export abstract class AccountService {
|
||||
* @param lastActivity
|
||||
*/
|
||||
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
|
||||
/**
|
||||
* Show the account switcher.
|
||||
* @param value
|
||||
*/
|
||||
abstract setShowHeader(visible: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class InternalAccountService extends AccountService {
|
||||
|
||||
@@ -429,6 +429,16 @@ describe("accountService", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("setShowHeader", () => {
|
||||
it("should update _showHeader$ when setShowHeader is called", async () => {
|
||||
expect(sut["_showHeader$"].value).toBe(true);
|
||||
|
||||
await sut.setShowHeader(false);
|
||||
|
||||
expect(sut["_showHeader$"].value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
distinctUntilChanged,
|
||||
shareReplay,
|
||||
combineLatest,
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
switchMap,
|
||||
filter,
|
||||
@@ -84,6 +85,7 @@ export const getOptionalUserId = map<Account | null, UserId | null>(
|
||||
export class AccountServiceImplementation implements InternalAccountService {
|
||||
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
|
||||
private activeAccountIdState: GlobalState<UserId | undefined>;
|
||||
private _showHeader$ = new BehaviorSubject<boolean>(true);
|
||||
|
||||
accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
activeAccount$: Observable<Account | null>;
|
||||
@@ -91,6 +93,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
accountVerifyNewDeviceLogin$: Observable<boolean>;
|
||||
sortedUserIds$: Observable<UserId[]>;
|
||||
nextUpAccount$: Observable<Account>;
|
||||
showHeader$ = this._showHeader$.asObservable();
|
||||
|
||||
constructor(
|
||||
private messagingService: MessagingService,
|
||||
@@ -262,6 +265,10 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
async setShowHeader(visible: boolean): Promise<void> {
|
||||
this._showHeader$.next(visible);
|
||||
}
|
||||
|
||||
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
|
||||
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
|
||||
return { ...oldAccountInfo, ...update };
|
||||
|
||||
@@ -19,6 +19,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Autofill */
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
WindowsNativeCredentialSync = "windows-native-credential-sync",
|
||||
WindowsDesktopAutotype = "windows-desktop-autotype",
|
||||
|
||||
/* Billing */
|
||||
@@ -86,6 +87,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
[FeatureFlag.WindowsNativeCredentialSync]: true,
|
||||
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface Fido2AuthenticatorGetAssertionParams {
|
||||
rpId: string;
|
||||
/** The hash of the serialized client data, provided by the client. */
|
||||
hash: BufferSource;
|
||||
allowCredentialDescriptorList: PublicKeyCredentialDescriptor[];
|
||||
allowCredentialDescriptorList?: PublicKeyCredentialDescriptor[];
|
||||
/** The effective user verification requirement for assertion, a Boolean value provided by the client. */
|
||||
requireUserVerification: boolean;
|
||||
/** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */
|
||||
|
||||
@@ -95,7 +95,7 @@ export abstract class Fido2UserInterfaceSession {
|
||||
*/
|
||||
abstract confirmNewCredential(
|
||||
params: NewCredentialParams,
|
||||
): Promise<{ cipherId: string; userVerified: boolean }>;
|
||||
): Promise<{ cipherId?: string; userVerified: boolean }>;
|
||||
|
||||
/**
|
||||
* Make sure that the vault is unlocked.
|
||||
|
||||
@@ -128,6 +128,7 @@ export class Fido2AuthenticatorService<ParentWindowReference>
|
||||
let userVerified = false;
|
||||
let credentialId: string;
|
||||
let pubKeyDer: ArrayBuffer;
|
||||
|
||||
const response = await userInterfaceSession.confirmNewCredential({
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.name,
|
||||
@@ -189,7 +190,7 @@ export class Fido2AuthenticatorService<ParentWindowReference>
|
||||
}
|
||||
const reencrypted = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(reencrypted);
|
||||
await this.cipherService.clearCache(activeUserId);
|
||||
// await this.cipherService.clearCache(activeUserId);
|
||||
credentialId = fido2Credential.credentialId;
|
||||
} catch (error) {
|
||||
this.logService?.error(
|
||||
@@ -457,8 +458,10 @@ export class Fido2AuthenticatorService<ParentWindowReference>
|
||||
}
|
||||
|
||||
private async findCredentialsByRp(rpId: string): Promise<CipherView[]> {
|
||||
this.logService.debug("[findCredentialByRp]:", rpId);
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const ciphers = await this.cipherService.getAllDecrypted(activeUserId);
|
||||
this.logService.debug("[findCredentialsByRp] ciphers:", ciphers);
|
||||
return ciphers.filter(
|
||||
(cipher) =>
|
||||
!cipher.isDeleted &&
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
|
||||
describe("Fido2 Utils", () => {
|
||||
@@ -67,4 +73,62 @@ describe("Fido2 Utils", () => {
|
||||
expect(expectedArray).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cipherHasNoOtherPasskeys(...)", () => {
|
||||
const emptyPasskeyCipher = mock<CipherView>({
|
||||
id: "id-5",
|
||||
localData: { lastUsedDate: 222 },
|
||||
name: "name-5",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "username-5",
|
||||
password: "password",
|
||||
uri: "https://example.com",
|
||||
fido2Credentials: [],
|
||||
},
|
||||
});
|
||||
|
||||
const passkeyCipher = mock<CipherView>({
|
||||
id: "id-5",
|
||||
localData: { lastUsedDate: 222 },
|
||||
name: "name-5",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "username-5",
|
||||
password: "password",
|
||||
uri: "https://example.com",
|
||||
fido2Credentials: [
|
||||
mock<Fido2CredentialView>({
|
||||
credentialId: "credential-id",
|
||||
rpName: "credential-name",
|
||||
userHandle: "user-handle-1",
|
||||
userName: "credential-username",
|
||||
rpId: "jest-testing-website.com",
|
||||
}),
|
||||
mock<Fido2CredentialView>({
|
||||
credentialId: "credential-id",
|
||||
rpName: "credential-name",
|
||||
userHandle: "user-handle-2",
|
||||
userName: "credential-username",
|
||||
rpId: "jest-testing-website.com",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it("should return true when there is no userHandle", () => {
|
||||
const userHandle = "user-handle-1";
|
||||
expect(Fido2Utils.cipherHasNoOtherPasskeys(emptyPasskeyCipher, userHandle)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return true when userHandle matches", () => {
|
||||
const userHandle = "user-handle-1";
|
||||
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false when userHandle doesn't match", () => {
|
||||
const userHandle = "testing";
|
||||
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
import type {
|
||||
AssertCredentialResult,
|
||||
@@ -111,4 +113,16 @@ export class Fido2Utils {
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
|
||||
* @param userHandle
|
||||
*/
|
||||
static cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
|
||||
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,63 @@
|
||||
import { guidToRawFormat } from "./guid-utils";
|
||||
import { guidToRawFormat, guidToStandardFormat } from "./guid-utils";
|
||||
|
||||
const workingExamples: [string, Uint8Array][] = [
|
||||
[
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
new Uint8Array([
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
]),
|
||||
],
|
||||
[
|
||||
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
|
||||
new Uint8Array([
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7,
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
describe("guid-utils", () => {
|
||||
describe("guidToRawFormat", () => {
|
||||
it.each(workingExamples)(
|
||||
"returns UUID in binary format when given a valid UUID string",
|
||||
(input, expected) => {
|
||||
const result = guidToRawFormat(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
[
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
],
|
||||
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
|
||||
[
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7,
|
||||
],
|
||||
],
|
||||
])("returns UUID in binary format when given a valid UUID string", (input, expected) => {
|
||||
const result = guidToRawFormat(input);
|
||||
|
||||
expect(result).toEqual(new Uint8Array(expected));
|
||||
"invalid",
|
||||
"",
|
||||
"",
|
||||
"00000000-0000-0000-0000-0000000000000000",
|
||||
"00000000-0000-0000-0000-000000",
|
||||
])("throws an error when given an invalid UUID string", (input) => {
|
||||
expect(() => guidToRawFormat(input)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws an error when given an invalid UUID string", () => {
|
||||
expect(() => guidToRawFormat("invalid")).toThrow(TypeError);
|
||||
describe("guidToStandardFormat", () => {
|
||||
it.each(workingExamples)(
|
||||
"returns UUID in standard format when given a valid UUID array buffer",
|
||||
(expected, input) => {
|
||||
const result = guidToStandardFormat(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
new Uint8Array(),
|
||||
new Uint8Array([]),
|
||||
new Uint8Array([
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7,
|
||||
]),
|
||||
])("throws an error when given an invalid UUID array buffer", (input) => {
|
||||
expect(() => guidToStandardFormat(input)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,10 @@ export function guidToRawFormat(guid: string) {
|
||||
|
||||
/** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */
|
||||
export function guidToStandardFormat(bufferSource: BufferSource) {
|
||||
if (bufferSource.byteLength !== 16) {
|
||||
throw TypeError("BufferSource length is invalid");
|
||||
}
|
||||
|
||||
const arr =
|
||||
bufferSource instanceof ArrayBuffer
|
||||
? new Uint8Array(bufferSource)
|
||||
|
||||
@@ -68,6 +68,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
/** When true, will override the match strategy for the cipher if it is Never. */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<CipherView[]>;
|
||||
abstract getAllDecryptedForIds(userId: UserId, ids: string[]): Promise<CipherView[]>;
|
||||
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
|
||||
ciphers: C[],
|
||||
url: string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user