import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { CipherService } from "../../abstractions/cipher.service"; import { Fido2AlgorithmIdentifier, Fido2AuthenticatorError, Fido2AuthenticatorErrorCode, Fido2AuthenticatorGetAssertionParams, Fido2AuthenticatorGetAssertionResult, Fido2AuthenticatorMakeCredentialResult, Fido2AuthenticatorMakeCredentialsParams, Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction, PublicKeyCredentialDescriptor, } from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; import { FallbackRequestedError } from "../../abstractions/fido2/fido2-client.service.abstraction"; import { Fido2UserInterfaceService } from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; import { SyncService } from "../../abstractions/sync/sync.service.abstraction"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherView } from "../../models/view/cipher.view"; import { Fido2CredentialView } from "../../models/view/fido2-credential.view"; import { CBOR } from "./cbor"; import { joseToDer } from "./ecdsa-utils"; import { Fido2Utils } from "./fido2-utils"; import { guidToRawFormat, guidToStandardFormat } from "./guid-utils"; // AAGUID: d548826e-79b4-db40-a3d8-11116f7e8349 export const AAGUID = new Uint8Array([ 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49, ]); const KeyUsages: KeyUsage[] = ["sign"]; /** * Bitwarden implementation of the WebAuthn Authenticator Model as described by W3C * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model * * It is highly recommended that the W3C specification is used a reference when reading this code. */ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstraction { constructor( private cipherService: CipherService, private userInterface: Fido2UserInterfaceService, private syncService: SyncService, private logService?: LogService ) {} async makeCredential( params: Fido2AuthenticatorMakeCredentialsParams, tab: chrome.tabs.Tab, abortController?: AbortController ): Promise { const userInterfaceSession = await this.userInterface.newSession( params.fallbackSupported, tab, abortController ); try { if (params.credTypesAndPubKeyAlgs.every((p) => p.alg !== Fido2AlgorithmIdentifier.ES256)) { const requestedAlgorithms = params.credTypesAndPubKeyAlgs.map((p) => p.alg).join(", "); this.logService?.warning( `[Fido2Authenticator] No compatible algorithms found, RP requested: ${requestedAlgorithms}` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotSupported); } if ( params.requireResidentKey != undefined && typeof params.requireResidentKey !== "boolean" ) { this.logService?.error( `[Fido2Authenticator] Invalid 'requireResidentKey' value: ${String( params.requireResidentKey )}` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } if ( params.requireUserVerification != undefined && typeof params.requireUserVerification !== "boolean" ) { this.logService?.error( `[Fido2Authenticator] Invalid 'requireUserVerification' value: ${String( params.requireUserVerification )}` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } await userInterfaceSession.ensureUnlockedVault(); await this.syncService.fullSync(false); const existingCipherIds = await this.findExcludedCredentials( params.excludeCredentialDescriptorList ); if (existingCipherIds.length > 0) { this.logService?.info( `[Fido2Authenticator] Aborting due to excluded credential found in vault.` ); await userInterfaceSession.informExcludedCredential(existingCipherIds); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } let cipher: CipherView; let fido2Credential: Fido2CredentialView; let keyPair: CryptoKeyPair; let userVerified = false; let credentialId: string; const response = await userInterfaceSession.confirmNewCredential({ credentialName: params.rpEntity.name, userName: params.userEntity.displayName, userVerification: params.requireUserVerification, }); const cipherId = response.cipherId; userVerified = response.userVerified; if (cipherId === undefined) { this.logService?.warning( `[Fido2Authenticator] Aborting because user confirmation was not recieved.` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } try { keyPair = await createKeyPair(); const encrypted = await this.cipherService.get(cipherId); cipher = await encrypted.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(encrypted) ); if ( !userVerified && (params.requireUserVerification || cipher.reprompt !== CipherRepromptType.None) ) { this.logService?.warning( `[Fido2Authenticator] Aborting because user verification was unsuccessful.` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } fido2Credential = await createKeyView(params, keyPair.privateKey); cipher.login.fido2Credentials = [fido2Credential]; const reencrypted = await this.cipherService.encrypt(cipher); await this.cipherService.updateWithServer(reencrypted); credentialId = fido2Credential.credentialId; } catch (error) { this.logService?.error( `[Fido2Authenticator] Aborting because of unknown error when creating credential: ${error}` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } const authData = await generateAuthData({ rpId: params.rpEntity.id, credentialId: guidToRawFormat(credentialId), counter: fido2Credential.counter, userPresence: true, userVerification: userVerified, keyPair, }); const attestationObject = new Uint8Array( CBOR.encode({ fmt: "none", attStmt: {}, authData, }) ); return { credentialId: guidToRawFormat(credentialId), attestationObject, authData, publicKeyAlgorithm: -7, }; } finally { userInterfaceSession.close(); } } async getAssertion( params: Fido2AuthenticatorGetAssertionParams, tab: chrome.tabs.Tab, abortController?: AbortController ): Promise { const userInterfaceSession = await this.userInterface.newSession( params.fallbackSupported, tab, abortController ); try { if ( params.requireUserVerification != undefined && typeof params.requireUserVerification !== "boolean" ) { this.logService?.error( `[Fido2Authenticator] Invalid 'requireUserVerification' value: ${String( params.requireUserVerification )}` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } let cipherOptions: CipherView[]; await userInterfaceSession.ensureUnlockedVault(); await this.syncService.fullSync(false); if (params.allowCredentialDescriptorList?.length > 0) { cipherOptions = await this.findCredentialsById( params.allowCredentialDescriptorList, params.rpId ); } else { cipherOptions = await this.findCredentialsByRp(params.rpId); } if (cipherOptions.length === 0) { this.logService?.info( `[Fido2Authenticator] Aborting because no matching credentials were found in the vault.` ); if (params.fallbackSupported) { throw new FallbackRequestedError(); } await userInterfaceSession.informCredentialNotFound(); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } const response = await userInterfaceSession.pickCredential({ cipherIds: cipherOptions.map((cipher) => cipher.id), userVerification: params.requireUserVerification, }); const selectedCipherId = response.cipherId; const userVerified = response.userVerified; const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId); if (selectedCipher === undefined) { this.logService?.error( `[Fido2Authenticator] Aborting because the selected credential could not be found.` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } if ( !userVerified && (params.requireUserVerification || selectedCipher.reprompt !== CipherRepromptType.None) ) { this.logService?.warning( `[Fido2Authenticator] Aborting because user verification was unsuccessful.` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } try { const selectedFido2Credential = selectedCipher.login.fido2Credentials[0]; const selectedCredentialId = selectedFido2Credential.credentialId; ++selectedFido2Credential.counter; selectedCipher.localData = { ...selectedCipher.localData, lastUsedDate: new Date().getTime(), }; const encrypted = await this.cipherService.encrypt(selectedCipher); await this.cipherService.updateWithServer(encrypted); const authenticatorData = await generateAuthData({ rpId: selectedFido2Credential.rpId, credentialId: guidToRawFormat(selectedCredentialId), counter: selectedFido2Credential.counter, userPresence: true, userVerification: userVerified, }); const signature = await generateSignature({ authData: authenticatorData, clientDataHash: params.hash, privateKey: await getPrivateKeyFromFido2Credential(selectedFido2Credential), }); return { authenticatorData, selectedCredential: { id: guidToRawFormat(selectedCredentialId), userHandle: Fido2Utils.stringToBuffer(selectedFido2Credential.userHandle), }, signature, }; } catch (error) { this.logService?.error( `[Fido2Authenticator] Aborting because of unknown error when asserting credential: ${error}` ); throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } } finally { userInterfaceSession.close(); } } /** Finds existing crendetials and returns the `cipherId` for each one */ private async findExcludedCredentials( credentials: PublicKeyCredentialDescriptor[] ): Promise { const ids: string[] = []; for (const credential of credentials) { try { ids.push(guidToStandardFormat(credential.id)); // eslint-disable-next-line no-empty } catch {} } if (ids.length === 0) { return []; } const ciphers = await this.cipherService.getAllDecrypted(); return ciphers .filter( (cipher) => !cipher.isDeleted && cipher.organizationId == undefined && cipher.type === CipherType.Login && cipher.login.hasFido2Credentials && ids.includes(cipher.login.fido2Credentials[0].credentialId) ) .map((cipher) => cipher.id); } private async findCredentialsById( credentials: PublicKeyCredentialDescriptor[], rpId: string ): Promise { const ids: string[] = []; for (const credential of credentials) { try { ids.push(guidToStandardFormat(credential.id)); // eslint-disable-next-line no-empty } catch {} } if (ids.length === 0) { return []; } const ciphers = await this.cipherService.getAllDecrypted(); return ciphers.filter( (cipher) => !cipher.isDeleted && cipher.type === CipherType.Login && cipher.login.hasFido2Credentials && cipher.login.fido2Credentials[0].rpId === rpId && ids.includes(cipher.login.fido2Credentials[0].credentialId) ); } private async findCredentialsByRp(rpId: string): Promise { const ciphers = await this.cipherService.getAllDecrypted(); return ciphers.filter( (cipher) => !cipher.isDeleted && cipher.type === CipherType.Login && cipher.login.hasFido2Credentials && cipher.login.fido2Credentials[0].rpId === rpId && cipher.login.fido2Credentials[0].discoverable ); } } async function createKeyPair() { return await crypto.subtle.generateKey( { name: "ECDSA", namedCurve: "P-256", }, true, KeyUsages ); } async function createKeyView( params: Fido2AuthenticatorMakeCredentialsParams, keyValue: CryptoKey ): Promise { if (keyValue.algorithm.name !== "ECDSA" && (keyValue.algorithm as any).namedCurve !== "P-256") { throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } const pkcs8Key = await crypto.subtle.exportKey("pkcs8", keyValue); const fido2Credential = new Fido2CredentialView(); fido2Credential.credentialId = Utils.newGuid(); fido2Credential.keyType = "public-key"; fido2Credential.keyAlgorithm = "ECDSA"; fido2Credential.keyCurve = "P-256"; fido2Credential.keyValue = Fido2Utils.bufferToString(pkcs8Key); fido2Credential.rpId = params.rpEntity.id; fido2Credential.userHandle = Fido2Utils.bufferToString(params.userEntity.id); fido2Credential.counter = 0; fido2Credential.rpName = params.rpEntity.name; fido2Credential.userDisplayName = params.userEntity.displayName; fido2Credential.discoverable = params.requireResidentKey; fido2Credential.creationDate = new Date(); return fido2Credential; } async function getPrivateKeyFromFido2Credential( fido2Credential: Fido2CredentialView ): Promise { const keyBuffer = Fido2Utils.stringToBuffer(fido2Credential.keyValue); return await crypto.subtle.importKey( "pkcs8", keyBuffer, { name: fido2Credential.keyAlgorithm, namedCurve: fido2Credential.keyCurve, } as EcKeyImportParams, true, KeyUsages ); } interface AuthDataParams { rpId: string; credentialId: BufferSource; userPresence: boolean; userVerification: boolean; counter: number; keyPair?: CryptoKeyPair; } async function generateAuthData(params: AuthDataParams) { const authData: Array = []; const rpIdHash = new Uint8Array( await crypto.subtle.digest({ name: "SHA-256" }, Utils.fromByteStringToArray(params.rpId)) ); authData.push(...rpIdHash); const flags = authDataFlags({ extensionData: false, attestationData: params.keyPair != undefined, userVerification: params.userVerification, userPresence: params.userPresence, }); authData.push(flags); // add 4 bytes of counter - we use time in epoch seconds as monotonic counter // TODO: Consider changing this to a cryptographically safe random number const counter = params.counter; authData.push( ((counter & 0xff000000) >> 24) & 0xff, ((counter & 0x00ff0000) >> 16) & 0xff, ((counter & 0x0000ff00) >> 8) & 0xff, counter & 0x000000ff ); if (params.keyPair) { // attestedCredentialData const attestedCredentialData: Array = []; attestedCredentialData.push(...AAGUID); // credentialIdLength (2 bytes) and credential Id const rawId = Fido2Utils.bufferSourceToUint8Array(params.credentialId); const credentialIdLength = [(rawId.length - (rawId.length & 0xff)) / 256, rawId.length & 0xff]; attestedCredentialData.push(...credentialIdLength); attestedCredentialData.push(...rawId); const publicKeyJwk = await crypto.subtle.exportKey("jwk", params.keyPair.publicKey); // COSE format of the EC256 key const keyX = Utils.fromUrlB64ToArray(publicKeyJwk.x); const keyY = Utils.fromUrlB64ToArray(publicKeyJwk.y); // Can't get `cbor-redux` to encode in CTAP2 canonical CBOR. So we do it manually: const coseBytes = new Uint8Array(77); coseBytes.set([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20], 0); coseBytes.set(keyX, 10); coseBytes.set([0x22, 0x58, 0x20], 10 + 32); coseBytes.set(keyY, 10 + 32 + 3); // credential public key - convert to array from CBOR encoded COSE key attestedCredentialData.push(...coseBytes); authData.push(...attestedCredentialData); } return new Uint8Array(authData); } interface SignatureParams { authData: Uint8Array; clientDataHash: BufferSource; privateKey: CryptoKey; } async function generateSignature(params: SignatureParams) { const sigBase = new Uint8Array([ ...params.authData, ...Fido2Utils.bufferSourceToUint8Array(params.clientDataHash), ]); const p1336_signature = new Uint8Array( await crypto.subtle.sign( { name: "ECDSA", hash: { name: "SHA-256" }, }, params.privateKey, sigBase ) ); const asn1Der_signature = joseToDer(p1336_signature, "ES256"); return asn1Der_signature; } interface Flags { extensionData: boolean; attestationData: boolean; userVerification: boolean; userPresence: boolean; } function authDataFlags(options: Flags): number { let flags = 0; if (options.extensionData) { flags |= 0b1000000; } if (options.attestationData) { flags |= 0b01000000; } if (options.userVerification) { flags |= 0b00000100; } if (options.userPresence) { flags |= 0b00000001; } return flags; }