mirror of
https://github.com/bitwarden/browser
synced 2025-12-18 09:13:33 +00:00
[PM-6400] Move core FIDO2 code from vault to platform ownership (#8044)
* [PM-6400] Move core FIDO2 code from vault to platform ownership - lib/common/vault/abstractions/fido2 -> lib/common/platform/abstractions/fido2 - lib/common/vault/services/fido2 -> lib/common/platform/services/fido2 * [PM-6400] fix: wrong imports
This commit is contained in:
494
libs/common/src/platform/services/fido2/cbor.ts
Normal file
494
libs/common/src/platform/services/fido2/cbor.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2016 Patrick Gansterer <paroga@paroga.com>
|
||||
Copyright (c) 2020-present Aaron Huggins <ahuggins@aaronhuggins.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Exported from GitHub release version 0.4.0
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
/** @hidden */
|
||||
const POW_2_24 = 5.960464477539063e-8;
|
||||
/** @hidden */
|
||||
const POW_2_32 = 4294967296;
|
||||
/** @hidden */
|
||||
const POW_2_53 = 9007199254740992;
|
||||
/** @hidden */
|
||||
const DECODE_CHUNK_SIZE = 8192;
|
||||
|
||||
/** @hidden */
|
||||
function objectIs(x: any, y: any) {
|
||||
if (typeof Object.is === "function") return Object.is(x, y);
|
||||
|
||||
// SameValue algorithm
|
||||
// Steps 1-5, 7-10
|
||||
if (x === y) {
|
||||
// Steps 6.b-6.e: +0 != -0
|
||||
return x !== 0 || 1 / x === 1 / y;
|
||||
}
|
||||
|
||||
// Step 6.a: NaN == NaN
|
||||
return x !== x && y !== y;
|
||||
}
|
||||
|
||||
/** A function that extracts tagged values. */
|
||||
type TaggedValueFunction = (value: any, tag: number) => TaggedValue;
|
||||
/** A function that extracts simple values. */
|
||||
type SimpleValueFunction = (value: any) => SimpleValue;
|
||||
|
||||
/** Convenience class for structuring a tagged value. */
|
||||
export class TaggedValue {
|
||||
constructor(value: any, tag: number) {
|
||||
this.value = value;
|
||||
this.tag = tag;
|
||||
}
|
||||
|
||||
value: any;
|
||||
tag: number;
|
||||
}
|
||||
|
||||
/** Convenience class for structuring a simple value. */
|
||||
export class SimpleValue {
|
||||
constructor(value: any) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Concise Binary Object Representation (CBOR) buffer into an object.
|
||||
* @param {ArrayBuffer|SharedArrayBuffer} data - A valid CBOR buffer.
|
||||
* @param {Function} [tagger] - A function that extracts tagged values. This function is called for each member of the object.
|
||||
* @param {Function} [simpleValue] - A function that extracts simple values. This function is called for each member of the object.
|
||||
* @returns {any} The CBOR buffer converted to a JavaScript value.
|
||||
*/
|
||||
export function decode<T = any>(
|
||||
data: ArrayBuffer | SharedArrayBuffer,
|
||||
tagger?: TaggedValueFunction,
|
||||
simpleValue?: SimpleValueFunction,
|
||||
): T {
|
||||
let dataView = new DataView(data);
|
||||
let ta = new Uint8Array(data);
|
||||
let offset = 0;
|
||||
let tagValueFunction: TaggedValueFunction = function (value: number, tag: number): any {
|
||||
return new TaggedValue(value, tag);
|
||||
};
|
||||
let simpleValFunction: SimpleValueFunction = function (value: number): SimpleValue {
|
||||
return undefined as unknown as SimpleValue;
|
||||
};
|
||||
|
||||
if (typeof tagger === "function") tagValueFunction = tagger;
|
||||
if (typeof simpleValue === "function") simpleValFunction = simpleValue;
|
||||
|
||||
function commitRead<T>(length: number, value: T): T {
|
||||
offset += length;
|
||||
return value;
|
||||
}
|
||||
function readArrayBuffer(length: number) {
|
||||
return commitRead(length, new Uint8Array(data, offset, length));
|
||||
}
|
||||
function readFloat16() {
|
||||
let tempArrayBuffer = new ArrayBuffer(4);
|
||||
let tempDataView = new DataView(tempArrayBuffer);
|
||||
let value = readUint16();
|
||||
|
||||
let sign = value & 0x8000;
|
||||
let exponent = value & 0x7c00;
|
||||
let fraction = value & 0x03ff;
|
||||
|
||||
if (exponent === 0x7c00) exponent = 0xff << 10;
|
||||
else if (exponent !== 0) exponent += (127 - 15) << 10;
|
||||
else if (fraction !== 0) return (sign ? -1 : 1) * fraction * POW_2_24;
|
||||
|
||||
tempDataView.setUint32(0, (sign << 16) | (exponent << 13) | (fraction << 13));
|
||||
return tempDataView.getFloat32(0);
|
||||
}
|
||||
function readFloat32(): number {
|
||||
return commitRead(4, dataView.getFloat32(offset));
|
||||
}
|
||||
function readFloat64(): number {
|
||||
return commitRead(8, dataView.getFloat64(offset));
|
||||
}
|
||||
function readUint8(): number {
|
||||
return commitRead(1, ta[offset]);
|
||||
}
|
||||
function readUint16(): number {
|
||||
return commitRead(2, dataView.getUint16(offset));
|
||||
}
|
||||
function readUint32(): number {
|
||||
return commitRead(4, dataView.getUint32(offset));
|
||||
}
|
||||
function readUint64(): number {
|
||||
return readUint32() * POW_2_32 + readUint32();
|
||||
}
|
||||
function readBreak(): boolean {
|
||||
if (ta[offset] !== 0xff) return false;
|
||||
offset += 1;
|
||||
return true;
|
||||
}
|
||||
function readLength(additionalInformation: number): number {
|
||||
if (additionalInformation < 24) return additionalInformation;
|
||||
if (additionalInformation === 24) return readUint8();
|
||||
if (additionalInformation === 25) return readUint16();
|
||||
if (additionalInformation === 26) return readUint32();
|
||||
if (additionalInformation === 27) return readUint64();
|
||||
if (additionalInformation === 31) return -1;
|
||||
throw new Error("Invalid length encoding");
|
||||
}
|
||||
function readIndefiniteStringLength(majorType: number): number {
|
||||
let initialByte = readUint8();
|
||||
if (initialByte === 0xff) return -1;
|
||||
let length = readLength(initialByte & 0x1f);
|
||||
if (length < 0 || initialByte >> 5 !== majorType)
|
||||
throw new Error("Invalid indefinite length element");
|
||||
return length;
|
||||
}
|
||||
|
||||
function appendUtf16Data(utf16data: number[], length: number) {
|
||||
for (let i = 0; i < length; ++i) {
|
||||
let value = readUint8();
|
||||
if (value & 0x80) {
|
||||
if (value < 0xe0) {
|
||||
value = ((value & 0x1f) << 6) | (readUint8() & 0x3f);
|
||||
length -= 1;
|
||||
} else if (value < 0xf0) {
|
||||
value = ((value & 0x0f) << 12) | ((readUint8() & 0x3f) << 6) | (readUint8() & 0x3f);
|
||||
length -= 2;
|
||||
} else {
|
||||
value =
|
||||
((value & 0x0f) << 18) |
|
||||
((readUint8() & 0x3f) << 12) |
|
||||
((readUint8() & 0x3f) << 6) |
|
||||
(readUint8() & 0x3f);
|
||||
length -= 3;
|
||||
}
|
||||
}
|
||||
|
||||
if (value < 0x10000) {
|
||||
utf16data.push(value);
|
||||
} else {
|
||||
value -= 0x10000;
|
||||
utf16data.push(0xd800 | (value >> 10));
|
||||
utf16data.push(0xdc00 | (value & 0x3ff));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decodeItem(): any {
|
||||
let initialByte = readUint8();
|
||||
let majorType = initialByte >> 5;
|
||||
let additionalInformation = initialByte & 0x1f;
|
||||
let i;
|
||||
let length;
|
||||
|
||||
if (majorType === 7) {
|
||||
switch (additionalInformation) {
|
||||
case 25:
|
||||
return readFloat16();
|
||||
case 26:
|
||||
return readFloat32();
|
||||
case 27:
|
||||
return readFloat64();
|
||||
}
|
||||
}
|
||||
|
||||
length = readLength(additionalInformation);
|
||||
if (length < 0 && (majorType < 2 || 6 < majorType)) throw new Error("Invalid length");
|
||||
|
||||
switch (majorType) {
|
||||
case 0:
|
||||
return length;
|
||||
case 1:
|
||||
return -1 - length;
|
||||
case 2:
|
||||
if (length < 0) {
|
||||
let elements = [];
|
||||
let fullArrayLength = 0;
|
||||
while ((length = readIndefiniteStringLength(majorType)) >= 0) {
|
||||
fullArrayLength += length;
|
||||
elements.push(readArrayBuffer(length));
|
||||
}
|
||||
let fullArray = new Uint8Array(fullArrayLength);
|
||||
let fullArrayOffset = 0;
|
||||
for (i = 0; i < elements.length; ++i) {
|
||||
fullArray.set(elements[i], fullArrayOffset);
|
||||
fullArrayOffset += elements[i].length;
|
||||
}
|
||||
return fullArray;
|
||||
}
|
||||
return readArrayBuffer(length);
|
||||
case 3:
|
||||
let utf16data: number[] = [];
|
||||
if (length < 0) {
|
||||
while ((length = readIndefiniteStringLength(majorType)) >= 0)
|
||||
appendUtf16Data(utf16data, length);
|
||||
} else {
|
||||
appendUtf16Data(utf16data, length);
|
||||
}
|
||||
let string = "";
|
||||
for (i = 0; i < utf16data.length; i += DECODE_CHUNK_SIZE) {
|
||||
string += String.fromCharCode.apply(null, utf16data.slice(i, i + DECODE_CHUNK_SIZE));
|
||||
}
|
||||
return string;
|
||||
case 4:
|
||||
let retArray;
|
||||
if (length < 0) {
|
||||
retArray = [];
|
||||
while (!readBreak()) retArray.push(decodeItem());
|
||||
} else {
|
||||
retArray = new Array(length);
|
||||
for (i = 0; i < length; ++i) retArray[i] = decodeItem();
|
||||
}
|
||||
return retArray;
|
||||
case 5:
|
||||
let retObject: any = {};
|
||||
for (i = 0; i < length || (length < 0 && !readBreak()); ++i) {
|
||||
let key = decodeItem();
|
||||
retObject[key] = decodeItem();
|
||||
}
|
||||
return retObject;
|
||||
case 6:
|
||||
return tagValueFunction(decodeItem(), length);
|
||||
case 7:
|
||||
switch (length) {
|
||||
case 20:
|
||||
return false;
|
||||
case 21:
|
||||
return true;
|
||||
case 22:
|
||||
return null;
|
||||
case 23:
|
||||
return undefined;
|
||||
default:
|
||||
return simpleValFunction(length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ret = decodeItem();
|
||||
if (offset !== data.byteLength) throw new Error("Remaining bytes");
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JavaScript value to a Concise Binary Object Representation (CBOR) buffer.
|
||||
* @param {any} value - A JavaScript value, usually an object or array, to be converted.
|
||||
* @returns {ArrayBuffer} The JavaScript value converted to CBOR format.
|
||||
*/
|
||||
export function encode<T = any>(value: T): ArrayBuffer {
|
||||
let data = new ArrayBuffer(256);
|
||||
let dataView = new DataView(data);
|
||||
let byteView = new Uint8Array(data);
|
||||
let lastLength: number;
|
||||
let offset = 0;
|
||||
|
||||
function prepareWrite(length: number): DataView {
|
||||
let newByteLength = data.byteLength;
|
||||
let requiredLength = offset + length;
|
||||
while (newByteLength < requiredLength) newByteLength <<= 1;
|
||||
if (newByteLength !== data.byteLength) {
|
||||
let oldDataView = dataView;
|
||||
data = new ArrayBuffer(newByteLength);
|
||||
dataView = new DataView(data);
|
||||
byteView = new Uint8Array(data);
|
||||
let uint32count = (offset + 3) >> 2;
|
||||
for (let i = 0; i < uint32count; ++i)
|
||||
dataView.setUint32(i << 2, oldDataView.getUint32(i << 2));
|
||||
}
|
||||
|
||||
lastLength = length;
|
||||
return dataView;
|
||||
}
|
||||
function commitWrite(...args: any[]) {
|
||||
offset += lastLength;
|
||||
}
|
||||
function writeFloat64(val: number) {
|
||||
commitWrite(prepareWrite(8).setFloat64(offset, val));
|
||||
}
|
||||
function writeUint8(val: number) {
|
||||
commitWrite(prepareWrite(1).setUint8(offset, val));
|
||||
}
|
||||
function writeUint8Array(val: number[] | Uint8Array) {
|
||||
prepareWrite(val.length);
|
||||
byteView.set(val, offset);
|
||||
commitWrite();
|
||||
}
|
||||
function writeUint16(val: number) {
|
||||
commitWrite(prepareWrite(2).setUint16(offset, val));
|
||||
}
|
||||
function writeUint32(val: number) {
|
||||
commitWrite(prepareWrite(4).setUint32(offset, val));
|
||||
}
|
||||
function writeUint64(val: number) {
|
||||
let low = val % POW_2_32;
|
||||
let high = (val - low) / POW_2_32;
|
||||
let view = prepareWrite(8);
|
||||
view.setUint32(offset, high);
|
||||
view.setUint32(offset + 4, low);
|
||||
commitWrite();
|
||||
}
|
||||
function writeVarUint(val: number, mod: number = 0) {
|
||||
if (val <= 0xff) {
|
||||
if (val < 24) {
|
||||
writeUint8(val | mod);
|
||||
} else {
|
||||
writeUint8(0x18 | mod);
|
||||
writeUint8(val);
|
||||
}
|
||||
} else if (val <= 0xffff) {
|
||||
writeUint8(0x19 | mod);
|
||||
writeUint16(val);
|
||||
} else if (val <= 0xffffffff) {
|
||||
writeUint8(0x1a | mod);
|
||||
writeUint32(val);
|
||||
} else {
|
||||
writeUint8(0x1b | mod);
|
||||
writeUint64(val);
|
||||
}
|
||||
}
|
||||
function writeTypeAndLength(type: number, length: number) {
|
||||
if (length < 24) {
|
||||
writeUint8((type << 5) | length);
|
||||
} else if (length < 0x100) {
|
||||
writeUint8((type << 5) | 24);
|
||||
writeUint8(length);
|
||||
} else if (length < 0x10000) {
|
||||
writeUint8((type << 5) | 25);
|
||||
writeUint16(length);
|
||||
} else if (length < 0x100000000) {
|
||||
writeUint8((type << 5) | 26);
|
||||
writeUint32(length);
|
||||
} else {
|
||||
writeUint8((type << 5) | 27);
|
||||
writeUint64(length);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeItem(val: any) {
|
||||
let i;
|
||||
|
||||
if (val === false) return writeUint8(0xf4);
|
||||
if (val === true) return writeUint8(0xf5);
|
||||
if (val === null) return writeUint8(0xf6);
|
||||
if (val === undefined) return writeUint8(0xf7);
|
||||
if (objectIs(val, -0)) return writeUint8Array([0xf9, 0x80, 0x00]);
|
||||
|
||||
switch (typeof val) {
|
||||
case "number":
|
||||
if (Math.floor(val) === val) {
|
||||
if (0 <= val && val <= POW_2_53) return writeTypeAndLength(0, val);
|
||||
if (-POW_2_53 <= val && val < 0) return writeTypeAndLength(1, -(val + 1));
|
||||
}
|
||||
writeUint8(0xfb);
|
||||
return writeFloat64(val);
|
||||
|
||||
case "string":
|
||||
let utf8data = [];
|
||||
for (i = 0; i < val.length; ++i) {
|
||||
let charCode = val.charCodeAt(i);
|
||||
if (charCode < 0x80) {
|
||||
utf8data.push(charCode);
|
||||
} else if (charCode < 0x800) {
|
||||
utf8data.push(0xc0 | (charCode >> 6));
|
||||
utf8data.push(0x80 | (charCode & 0x3f));
|
||||
} else if (charCode < 0xd800 || charCode >= 0xe000) {
|
||||
utf8data.push(0xe0 | (charCode >> 12));
|
||||
utf8data.push(0x80 | ((charCode >> 6) & 0x3f));
|
||||
utf8data.push(0x80 | (charCode & 0x3f));
|
||||
} else {
|
||||
charCode = (charCode & 0x3ff) << 10;
|
||||
charCode |= val.charCodeAt(++i) & 0x3ff;
|
||||
charCode += 0x10000;
|
||||
|
||||
utf8data.push(0xf0 | (charCode >> 18));
|
||||
utf8data.push(0x80 | ((charCode >> 12) & 0x3f));
|
||||
utf8data.push(0x80 | ((charCode >> 6) & 0x3f));
|
||||
utf8data.push(0x80 | (charCode & 0x3f));
|
||||
}
|
||||
}
|
||||
|
||||
writeTypeAndLength(3, utf8data.length);
|
||||
return writeUint8Array(utf8data);
|
||||
|
||||
default:
|
||||
let length;
|
||||
let converted;
|
||||
if (Array.isArray(val)) {
|
||||
length = val.length;
|
||||
writeTypeAndLength(4, length);
|
||||
for (i = 0; i < length; i += 1) encodeItem(val[i]);
|
||||
} else if (val instanceof Uint8Array) {
|
||||
writeTypeAndLength(2, val.length);
|
||||
writeUint8Array(val);
|
||||
} else if (ArrayBuffer.isView(val)) {
|
||||
converted = new Uint8Array(val.buffer);
|
||||
writeTypeAndLength(2, converted.length);
|
||||
writeUint8Array(converted);
|
||||
} else if (
|
||||
val instanceof ArrayBuffer ||
|
||||
(typeof SharedArrayBuffer === "function" && val instanceof SharedArrayBuffer)
|
||||
) {
|
||||
converted = new Uint8Array(val);
|
||||
writeTypeAndLength(2, converted.length);
|
||||
writeUint8Array(converted);
|
||||
} else if (val instanceof TaggedValue) {
|
||||
writeVarUint(val.tag, 0b11000000);
|
||||
encodeItem(val.value);
|
||||
} else {
|
||||
let keys = Object.keys(val);
|
||||
length = keys.length;
|
||||
writeTypeAndLength(5, length);
|
||||
for (i = 0; i < length; i += 1) {
|
||||
let key = keys[i];
|
||||
encodeItem(key);
|
||||
encodeItem(val[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
encodeItem(value);
|
||||
|
||||
if ("slice" in data) return data.slice(0, offset);
|
||||
|
||||
let ret = new ArrayBuffer(offset);
|
||||
let retView = new DataView(ret);
|
||||
for (let i = 0; i < offset; ++i) retView.setUint8(i, dataView.getUint8(i));
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* An intrinsic object that provides functions to convert JavaScript values
|
||||
* to and from the Concise Binary Object Representation (CBOR) format.
|
||||
*/
|
||||
export const CBOR: {
|
||||
decode: <T = any>(
|
||||
data: ArrayBuffer | SharedArrayBuffer,
|
||||
tagger?: TaggedValueFunction,
|
||||
simpleValue?: SimpleValueFunction,
|
||||
) => T;
|
||||
encode: <T = any>(value: T) => ArrayBuffer;
|
||||
} = {
|
||||
decode,
|
||||
encode,
|
||||
};
|
||||
83
libs/common/src/platform/services/fido2/domain-utils.spec.ts
Normal file
83
libs/common/src/platform/services/fido2/domain-utils.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { isValidRpId } from "./domain-utils";
|
||||
|
||||
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm.
|
||||
describe("validateRpId", () => {
|
||||
it("should not be valid when rpId is more specific than origin", () => {
|
||||
const rpId = "sub.login.bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not be valid when effective domains of rpId and origin do not match", () => {
|
||||
const rpId = "passwordless.dev";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => {
|
||||
const rpId = "login.passwordless.dev";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not be valid when rpId and origin are both different TLD", () => {
|
||||
const rpId = "bitwarden";
|
||||
const origin = "localhost";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
|
||||
// Only allow localhost for rpId, need to properly investigate the implications of
|
||||
// adding support for ip-addresses and other TLDs
|
||||
it("should not be valid when rpId and origin are both the same TLD", () => {
|
||||
const rpId = "bitwarden";
|
||||
const origin = "bitwarden";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not be valid when rpId and origin are ip-addresses", () => {
|
||||
const rpId = "127.0.0.1";
|
||||
const origin = "127.0.0.1";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
|
||||
it("should be valid when domains of rpId and origin are localhost", () => {
|
||||
const rpId = "localhost";
|
||||
const origin = "https://localhost:8080";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
|
||||
it("should be valid when domains of rpId and origin are the same", () => {
|
||||
const rpId = "bitwarden.com";
|
||||
const origin = "https://bitwarden.com";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
|
||||
it("should be valid when origin is a subdomain of rpId", () => {
|
||||
const rpId = "bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
|
||||
it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => {
|
||||
const rpId = "login.bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
|
||||
it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => {
|
||||
const rpId = "login.bitwarden.com";
|
||||
const origin = "https://sub.login.bitwarden.com:1337";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
});
|
||||
15
libs/common/src/platform/services/fido2/domain-utils.ts
Normal file
15
libs/common/src/platform/services/fido2/domain-utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { parse } from "tldts";
|
||||
|
||||
export function isValidRpId(rpId: string, origin: string) {
|
||||
const parsedOrigin = parse(origin, { allowPrivateDomains: true });
|
||||
const parsedRpId = parse(rpId, { allowPrivateDomains: true });
|
||||
|
||||
return (
|
||||
(parsedOrigin.domain == null &&
|
||||
parsedOrigin.hostname == parsedRpId.hostname &&
|
||||
parsedOrigin.hostname == "localhost") ||
|
||||
(parsedOrigin.domain != null &&
|
||||
parsedOrigin.domain == parsedRpId.domain &&
|
||||
parsedOrigin.subdomain.endsWith(parsedRpId.subdomain))
|
||||
);
|
||||
}
|
||||
83
libs/common/src/platform/services/fido2/ecdsa-utils.spec.ts
Normal file
83
libs/common/src/platform/services/fido2/ecdsa-utils.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { p1363ToDer } from "./ecdsa-utils";
|
||||
|
||||
describe("p1363ToDer", () => {
|
||||
let r: Uint8Array;
|
||||
let s: Uint8Array;
|
||||
|
||||
beforeEach(() => {
|
||||
r = randomBytes(32);
|
||||
s = randomBytes(32);
|
||||
});
|
||||
|
||||
it("should convert P1336 to DER when 'R' is positive and 'S' is positive", () => {
|
||||
r[0] = 0x14;
|
||||
s[0] = 0x32;
|
||||
const signature = new Uint8Array([...r, ...s]);
|
||||
|
||||
const result = p1363ToDer(signature);
|
||||
|
||||
expect(result).toEqual(new Uint8Array([0x30, 0x44, 0x02, 0x20, ...r, 0x02, 0x20, ...s]));
|
||||
});
|
||||
|
||||
it("should convert P1336 to DER when 'R' is negative and 'S' is negative", () => {
|
||||
r[0] = 0x94;
|
||||
s[0] = 0xaa;
|
||||
const signature = new Uint8Array([...r, ...s]);
|
||||
|
||||
const result = p1363ToDer(signature);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Uint8Array([0x30, 0x46, 0x02, 0x21, 0x00, ...r, 0x02, 0x21, 0x00, ...s]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should convert P1336 to DER when 'R' is negative and 'S' is positive", () => {
|
||||
r[0] = 0x94;
|
||||
s[0] = 0x32;
|
||||
const signature = new Uint8Array([...r, ...s]);
|
||||
|
||||
const result = p1363ToDer(signature);
|
||||
|
||||
expect(result).toEqual(new Uint8Array([0x30, 0x45, 0x02, 0x21, 0x00, ...r, 0x02, 0x20, ...s]));
|
||||
});
|
||||
|
||||
it("should convert P1336 to DER when 'R' is positive and 'S' is negative", () => {
|
||||
r[0] = 0x32;
|
||||
s[0] = 0x94;
|
||||
const signature = new Uint8Array([...r, ...s]);
|
||||
|
||||
const result = p1363ToDer(signature);
|
||||
|
||||
expect(result).toEqual(new Uint8Array([0x30, 0x45, 0x02, 0x20, ...r, 0x02, 0x21, 0x00, ...s]));
|
||||
});
|
||||
|
||||
it("should convert P1336 to DER when 'R' has leading zero and is negative and 'S' is positive", () => {
|
||||
r[0] = 0x00;
|
||||
r[1] = 0x94;
|
||||
s[0] = 0x32;
|
||||
const signature = new Uint8Array([...r, ...s]);
|
||||
|
||||
const result = p1363ToDer(signature);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Uint8Array([0x30, 0x44, 0x02, 0x20, 0x00, ...r.slice(1), 0x02, 0x20, ...s]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should convert P1336 to DER when 'R' is positive and 'S' has leading zero and is negative ", () => {
|
||||
r[0] = 0x32;
|
||||
s[0] = 0x00;
|
||||
s[1] = 0x94;
|
||||
const signature = new Uint8Array([...r, ...s]);
|
||||
|
||||
const result = p1363ToDer(signature);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Uint8Array([0x30, 0x44, 0x02, 0x20, ...r, 0x02, 0x20, 0x00, ...s.slice(1)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function randomBytes(length: number): Uint8Array {
|
||||
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
|
||||
}
|
||||
133
libs/common/src/platform/services/fido2/ecdsa-utils.ts
Normal file
133
libs/common/src/platform/services/fido2/ecdsa-utils.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
Copyright 2015 D2L Corporation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License. */
|
||||
|
||||
// Changes:
|
||||
// - Cherry-pick the methods that we have a need for.
|
||||
// - Add typings.
|
||||
// - Original code is made for running in node, this version is adapted to work in the browser.
|
||||
|
||||
// https://github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/param-bytes-for-alg.js
|
||||
|
||||
function getParamSize(keySize: number) {
|
||||
const result = ((keySize / 8) | 0) + (keySize % 8 === 0 ? 0 : 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
const paramBytesForAlg = {
|
||||
ES256: getParamSize(256),
|
||||
ES384: getParamSize(384),
|
||||
ES512: getParamSize(521),
|
||||
};
|
||||
|
||||
type Alg = keyof typeof paramBytesForAlg;
|
||||
|
||||
function getParamBytesForAlg(alg: Alg) {
|
||||
const paramBytes = paramBytesForAlg[alg];
|
||||
if (paramBytes) {
|
||||
return paramBytes;
|
||||
}
|
||||
|
||||
throw new Error('Unknown algorithm "' + alg + '"');
|
||||
}
|
||||
|
||||
// https://github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/ecdsa-sig-formatter.js
|
||||
|
||||
const MAX_OCTET = 0x80,
|
||||
CLASS_UNIVERSAL = 0,
|
||||
PRIMITIVE_BIT = 0x20,
|
||||
TAG_SEQ = 0x10,
|
||||
TAG_INT = 0x02,
|
||||
ENCODED_TAG_SEQ = TAG_SEQ | PRIMITIVE_BIT | (CLASS_UNIVERSAL << 6),
|
||||
ENCODED_TAG_INT = TAG_INT | (CLASS_UNIVERSAL << 6);
|
||||
|
||||
// Counts leading zeros and determines if there's a need for 0x00 padding
|
||||
function countPadding(
|
||||
buf: Uint8Array,
|
||||
start: number,
|
||||
end: number,
|
||||
): { padding: number; needs0x00: boolean } {
|
||||
let padding = 0;
|
||||
while (start + padding < end && buf[start + padding] === 0) {
|
||||
padding++;
|
||||
}
|
||||
|
||||
const needs0x00 = (buf[start + padding] & MAX_OCTET) === MAX_OCTET;
|
||||
return { padding, needs0x00 };
|
||||
}
|
||||
|
||||
export function p1363ToDer(signature: Uint8Array) {
|
||||
const alg = "ES256";
|
||||
const paramBytes = getParamBytesForAlg(alg);
|
||||
|
||||
const signatureBytes = signature.length;
|
||||
if (signatureBytes !== paramBytes * 2) {
|
||||
throw new TypeError(
|
||||
'"' +
|
||||
alg +
|
||||
'" signatures must be "' +
|
||||
paramBytes * 2 +
|
||||
'" bytes, saw "' +
|
||||
signatureBytes +
|
||||
'"',
|
||||
);
|
||||
}
|
||||
|
||||
const { padding: rPadding, needs0x00: rNeeds0x00 } = countPadding(signature, 0, paramBytes);
|
||||
const { padding: sPadding, needs0x00: sNeeds0x00 } = countPadding(
|
||||
signature,
|
||||
paramBytes,
|
||||
signature.length,
|
||||
);
|
||||
|
||||
const rActualLength = paramBytes - rPadding;
|
||||
const sActualLength = paramBytes - sPadding;
|
||||
|
||||
const rLength = rActualLength + (rNeeds0x00 ? 1 : 0);
|
||||
const sLength = sActualLength + (sNeeds0x00 ? 1 : 0);
|
||||
|
||||
const rsBytes = 2 + rLength + 2 + sLength;
|
||||
|
||||
const shortLength = rsBytes < MAX_OCTET;
|
||||
|
||||
const dst = new Uint8Array((shortLength ? 2 : 3) + rsBytes);
|
||||
|
||||
let offset = 0;
|
||||
dst[offset++] = ENCODED_TAG_SEQ;
|
||||
if (shortLength) {
|
||||
dst[offset++] = rsBytes;
|
||||
} else {
|
||||
dst[offset++] = MAX_OCTET | 1;
|
||||
dst[offset++] = rsBytes & 0xff;
|
||||
}
|
||||
|
||||
// Encoding 'R' component
|
||||
dst[offset++] = ENCODED_TAG_INT;
|
||||
dst[offset++] = rLength;
|
||||
if (rNeeds0x00) {
|
||||
dst[offset++] = 0;
|
||||
}
|
||||
dst.set(signature.subarray(rPadding, rPadding + rActualLength), offset);
|
||||
offset += rActualLength;
|
||||
|
||||
// Encoding 'S' component
|
||||
dst[offset++] = ENCODED_TAG_INT;
|
||||
dst[offset++] = sLength;
|
||||
if (sNeeds0x00) {
|
||||
dst[offset++] = 0;
|
||||
}
|
||||
dst.set(signature.subarray(paramBytes + sPadding, paramBytes + sPadding + sActualLength), offset);
|
||||
|
||||
return dst;
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
import { TextEncoder } from "util";
|
||||
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../../vault/enums/cipher-type";
|
||||
import { Cipher } from "../../../vault/models/domain/cipher";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
|
||||
import { LoginView } from "../../../vault/models/view/login.view";
|
||||
import {
|
||||
Fido2AuthenticatorErrorCode,
|
||||
Fido2AuthenticatorGetAssertionParams,
|
||||
Fido2AuthenticatorMakeCredentialsParams,
|
||||
} from "../../abstractions/fido2/fido2-authenticator.service.abstraction";
|
||||
import {
|
||||
Fido2UserInterfaceService,
|
||||
Fido2UserInterfaceSession,
|
||||
NewCredentialParams,
|
||||
} from "../../abstractions/fido2/fido2-user-interface.service.abstraction";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { CBOR } from "./cbor";
|
||||
import { AAGUID, Fido2AuthenticatorService } from "./fido2-authenticator.service";
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
import { guidToRawFormat } from "./guid-utils";
|
||||
|
||||
const RpId = "bitwarden.com";
|
||||
|
||||
describe("FidoAuthenticatorService", () => {
|
||||
let cipherService!: MockProxy<CipherService>;
|
||||
let userInterface!: MockProxy<Fido2UserInterfaceService>;
|
||||
let userInterfaceSession!: MockProxy<Fido2UserInterfaceSession>;
|
||||
let syncService!: MockProxy<SyncService>;
|
||||
let authenticator!: Fido2AuthenticatorService;
|
||||
let tab!: chrome.tabs.Tab;
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherService = mock<CipherService>();
|
||||
userInterface = mock<Fido2UserInterfaceService>();
|
||||
userInterfaceSession = mock<Fido2UserInterfaceSession>();
|
||||
userInterface.newSession.mockResolvedValue(userInterfaceSession);
|
||||
syncService = mock<SyncService>();
|
||||
authenticator = new Fido2AuthenticatorService(cipherService, userInterface, syncService);
|
||||
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
|
||||
});
|
||||
|
||||
describe("makeCredential", () => {
|
||||
let invalidParams!: InvalidParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
invalidParams = await createInvalidParams();
|
||||
});
|
||||
|
||||
describe("invalid input parameters", () => {
|
||||
// Spec: Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation.
|
||||
it("should throw error when input does not contain any supported algorithms", async () => {
|
||||
const result = async () =>
|
||||
await authenticator.makeCredential(invalidParams.unsupportedAlgorithm, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotSupported);
|
||||
});
|
||||
|
||||
it("should throw error when requireResidentKey has invalid value", async () => {
|
||||
const result = async () => await authenticator.makeCredential(invalidParams.invalidRk, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown);
|
||||
});
|
||||
|
||||
it("should throw error when requireUserVerification has invalid value", async () => {
|
||||
const result = async () => await authenticator.makeCredential(invalidParams.invalidUv, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown);
|
||||
});
|
||||
|
||||
/**
|
||||
* Spec: If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation.
|
||||
* Deviation: User verification is checked before checking for excluded credentials
|
||||
**/
|
||||
/** TODO: This test should only be activated if we disable support for user verification */
|
||||
it.skip("should throw error if requireUserVerification is set to true", async () => {
|
||||
const params = await createParams({ requireUserVerification: true });
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Constraint);
|
||||
});
|
||||
|
||||
it("should not request confirmation from user", async () => {
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: "75280e7e-a72e-4d6c-bf1e-d37238352f9b",
|
||||
userVerified: false,
|
||||
});
|
||||
const invalidParams = await createInvalidParams();
|
||||
|
||||
for (const p of Object.values(invalidParams)) {
|
||||
try {
|
||||
await authenticator.makeCredential(p, tab);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
}
|
||||
expect(userInterfaceSession.confirmNewCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("when extensions parameter is present", () => undefined);
|
||||
|
||||
describe("vault contains excluded credential", () => {
|
||||
let excludedCipher: CipherView;
|
||||
let params: Fido2AuthenticatorMakeCredentialsParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
excludedCipher = createCipherView(
|
||||
{ type: CipherType.Login },
|
||||
{ credentialId: Utils.newGuid() },
|
||||
);
|
||||
params = await createParams({
|
||||
excludeCredentialDescriptorList: [
|
||||
{
|
||||
id: guidToRawFormat(excludedCipher.login.fido2Credentials[0].credentialId),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
});
|
||||
cipherService.get.mockImplementation(async (id) =>
|
||||
id === excludedCipher.id ? ({ decrypt: () => excludedCipher } as any) : undefined,
|
||||
);
|
||||
cipherService.getAllDecrypted.mockResolvedValue([excludedCipher]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Spec: collect an authorization gesture confirming user consent for creating a new credential.
|
||||
* Deviation: Consent is not asked and the user is simply informed of the situation.
|
||||
**/
|
||||
it("should inform user", async () => {
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
|
||||
try {
|
||||
await authenticator.makeCredential(params, tab);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
|
||||
expect(userInterfaceSession.informExcludedCredential).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error", async () => {
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed);
|
||||
});
|
||||
|
||||
/** Devation: Organization ciphers are not checked against excluded credentials, even if the user has access to them. */
|
||||
it("should not inform user of duplication when the excluded credential belongs to an organization", async () => {
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
excludedCipher.organizationId = "someOrganizationId";
|
||||
|
||||
try {
|
||||
await authenticator.makeCredential(params, tab);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
|
||||
expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not inform user of duplication when input data does not pass checks", async () => {
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
const invalidParams = await createInvalidParams();
|
||||
|
||||
for (const p of Object.values(invalidParams)) {
|
||||
try {
|
||||
await authenticator.makeCredential(p, tab);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
}
|
||||
expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.todo(
|
||||
"should not throw error if the excluded credential has been marked as deleted in the vault",
|
||||
);
|
||||
});
|
||||
|
||||
describe("credential creation", () => {
|
||||
let existingCipher: CipherView;
|
||||
let params: Fido2AuthenticatorMakeCredentialsParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
existingCipher = createCipherView({ type: CipherType.Login });
|
||||
params = await createParams({ requireResidentKey: false });
|
||||
cipherService.get.mockImplementation(async (id) =>
|
||||
id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined,
|
||||
);
|
||||
cipherService.getAllDecrypted.mockResolvedValue([existingCipher]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Spec: Collect an authorization gesture confirming user consent for creating a new credential. The prompt for the authorization gesture is shown by the authenticator if it has its own output capability. The prompt SHOULD display rpEntity.id, rpEntity.name, userEntity.name and userEntity.displayName, if possible.
|
||||
* Deviation: Only `rpEntity.name` and `userEntity.name` is shown.
|
||||
* */
|
||||
for (const userVerification of [true, false]) {
|
||||
it(`should request confirmation from user when user verification is ${userVerification}`, async () => {
|
||||
params.requireUserVerification = userVerification;
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: userVerification,
|
||||
});
|
||||
|
||||
await authenticator.makeCredential(params, tab);
|
||||
|
||||
expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith({
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
userVerification,
|
||||
rpId: params.rpEntity.id,
|
||||
} as NewCredentialParams);
|
||||
});
|
||||
}
|
||||
|
||||
it("should save credential to vault if request confirmed by user", async () => {
|
||||
const encryptedCipher = Symbol();
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
|
||||
await authenticator.makeCredential(params, tab);
|
||||
|
||||
const saved = cipherService.encrypt.mock.lastCall?.[0];
|
||||
expect(saved).toEqual(
|
||||
expect.objectContaining({
|
||||
type: CipherType.Login,
|
||||
name: existingCipher.name,
|
||||
|
||||
login: expect.objectContaining({
|
||||
fido2Credentials: [
|
||||
expect.objectContaining({
|
||||
credentialId: expect.anything(),
|
||||
keyType: "public-key",
|
||||
keyAlgorithm: "ECDSA",
|
||||
keyCurve: "P-256",
|
||||
rpId: params.rpEntity.id,
|
||||
rpName: params.rpEntity.name,
|
||||
userHandle: Fido2Utils.bufferToString(params.userEntity.id),
|
||||
userName: params.userEntity.name,
|
||||
counter: 0,
|
||||
userDisplayName: params.userEntity.displayName,
|
||||
discoverable: false,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher);
|
||||
});
|
||||
|
||||
/** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error if user denies creation request", async () => {
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: undefined,
|
||||
userVerified: false,
|
||||
});
|
||||
const params = await createParams();
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed);
|
||||
});
|
||||
|
||||
it("should throw error if user verification fails and cipher requires reprompt", async () => {
|
||||
params.requireUserVerification = false;
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
const encryptedCipher = { ...existingCipher, reprompt: CipherRepromptType.Password };
|
||||
cipherService.get.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown);
|
||||
});
|
||||
|
||||
/** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */
|
||||
it("should throw unkown error if creation fails", async () => {
|
||||
const encryptedCipher = Symbol();
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`attestation of new credential`, () => {
|
||||
const cipherId = "75280e7e-a72e-4d6c-bf1e-d37238352f9b";
|
||||
const credentialId = "52217b91-73f1-4fea-b3f2-54a7959fd5aa";
|
||||
const credentialIdBytes = new Uint8Array([
|
||||
0x52, 0x21, 0x7b, 0x91, 0x73, 0xf1, 0x4f, 0xea, 0xb3, 0xf2, 0x54, 0xa7, 0x95, 0x9f, 0xd5,
|
||||
0xaa,
|
||||
]);
|
||||
let params: Fido2AuthenticatorMakeCredentialsParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
const cipher = createCipherView({ id: cipherId, type: CipherType.Login });
|
||||
params = await createParams();
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.get.mockImplementation(async (cipherId) =>
|
||||
cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined,
|
||||
);
|
||||
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
|
||||
cipherService.encrypt.mockImplementation(async (cipher) => {
|
||||
cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
|
||||
return {} as any;
|
||||
});
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
cipherService.updateWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
});
|
||||
|
||||
it("should return attestation object", async () => {
|
||||
const result = await authenticator.makeCredential(params, tab);
|
||||
|
||||
const attestationObject = CBOR.decode(
|
||||
Fido2Utils.bufferSourceToUint8Array(result.attestationObject).buffer,
|
||||
);
|
||||
|
||||
const encAuthData: Uint8Array = attestationObject.authData;
|
||||
const rpIdHash = encAuthData.slice(0, 32);
|
||||
const flags = encAuthData.slice(32, 33);
|
||||
const counter = encAuthData.slice(33, 37);
|
||||
const aaguid = encAuthData.slice(37, 53);
|
||||
const credentialIdLength = encAuthData.slice(53, 55);
|
||||
const credentialId = encAuthData.slice(55, 71);
|
||||
// Unsure how to test public key
|
||||
// const publicKey = encAuthData.slice(87);
|
||||
|
||||
expect(encAuthData.length).toBe(71 + 77);
|
||||
expect(attestationObject.fmt).toBe("none");
|
||||
expect(attestationObject.attStmt).toEqual({});
|
||||
expect(rpIdHash).toEqual(
|
||||
new Uint8Array([
|
||||
0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8,
|
||||
0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1,
|
||||
0xd0, 0x5c, 0x3d, 0xc3,
|
||||
]),
|
||||
);
|
||||
expect(flags).toEqual(new Uint8Array([0b01011001])); // UP = true, AT = true, BE = true, BS = true
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0, 0])); // 0 because of new counter
|
||||
expect(aaguid).toEqual(AAGUID);
|
||||
expect(credentialIdLength).toEqual(new Uint8Array([0, 16])); // 16 bytes because we're using GUIDs
|
||||
expect(credentialId).toEqual(credentialIdBytes);
|
||||
});
|
||||
});
|
||||
|
||||
async function createParams(
|
||||
params: Partial<Fido2AuthenticatorMakeCredentialsParams> = {},
|
||||
): Promise<Fido2AuthenticatorMakeCredentialsParams> {
|
||||
return {
|
||||
hash: params.hash ?? (await createClientDataHash()),
|
||||
rpEntity: params.rpEntity ?? {
|
||||
name: "Bitwarden",
|
||||
id: RpId,
|
||||
},
|
||||
userEntity: params.userEntity ?? {
|
||||
id: randomBytes(64),
|
||||
name: "jane.doe@bitwarden.com",
|
||||
displayName: "Jane Doe",
|
||||
icon: " data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAOhJREFUeNpiFI+9E8DAwDAfiAUYSAMfgDiQBVmzlSYnUTqPXf/OANWzngVZ87pKKaIMCGp/BjeEhRjFMKAjx8bQFC2CIs9CpHNxAiYGCsEQM4Cfiwm3AY9f/yZogIcRN4ZahAFv/jAcu4E7xMNtecEYpAakFqsX8me9Yvj07R+G5jR3foaJqWJgOZAaZMAIzAv/kQV05NgZ5hdIMMiKQJIIyEYrDU6wrYkTXjBcefQTvwGwwCoJFGJIBdoMArN3fmToWf+O4SMW14EMeI8rJ8Jcgexn9BwJCoNEaNbEACCN+DSDsjNAgAEAri9Zii/uDMsAAAAASUVORK5CYII=",
|
||||
},
|
||||
credTypesAndPubKeyAlgs: params.credTypesAndPubKeyAlgs ?? [
|
||||
{
|
||||
alg: -7, // ES256
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
excludeCredentialDescriptorList: params.excludeCredentialDescriptorList ?? [
|
||||
{
|
||||
id: randomBytes(16),
|
||||
transports: ["internal"],
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
requireResidentKey: params.requireResidentKey ?? false,
|
||||
requireUserVerification: params.requireUserVerification ?? false,
|
||||
fallbackSupported: params.fallbackSupported ?? false,
|
||||
extensions: params.extensions ?? {
|
||||
appid: undefined,
|
||||
appidExclude: undefined,
|
||||
credProps: undefined,
|
||||
uvm: false as boolean,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type InvalidParams = Awaited<ReturnType<typeof createInvalidParams>>;
|
||||
async function createInvalidParams() {
|
||||
return {
|
||||
unsupportedAlgorithm: await createParams({
|
||||
credTypesAndPubKeyAlgs: [{ alg: 9001, type: "public-key" }],
|
||||
}),
|
||||
invalidRk: await createParams({ requireResidentKey: "invalid-value" as any }),
|
||||
invalidUv: await createParams({
|
||||
requireUserVerification: "invalid-value" as any,
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
describe("getAssertion", () => {
|
||||
let invalidParams!: InvalidParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
invalidParams = await createInvalidParams();
|
||||
});
|
||||
|
||||
describe("invalid input parameters", () => {
|
||||
it("should throw error when requireUserVerification has invalid value", async () => {
|
||||
const result = async () => await authenticator.getAssertion(invalidParams.invalidUv, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown);
|
||||
});
|
||||
|
||||
/**
|
||||
* Spec: If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation.
|
||||
* Deviation: User verification is checked before checking for excluded credentials
|
||||
**/
|
||||
/** NOTE: This test should only be activated if we disable support for user verification */
|
||||
it.skip("should throw error if requireUserVerification is set to true", async () => {
|
||||
const params = await createParams({ requireUserVerification: true });
|
||||
|
||||
const result = async () => await authenticator.getAssertion(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Constraint);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vault is missing non-discoverable credential", () => {
|
||||
let credentialId: string;
|
||||
let params: Fido2AuthenticatorGetAssertionParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
credentialId = Utils.newGuid();
|
||||
params = await createParams({
|
||||
allowCredentialDescriptorList: [
|
||||
{ id: guidToRawFormat(credentialId), type: "public-key" },
|
||||
],
|
||||
rpId: RpId,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation.
|
||||
* Deviation: We do not throw error but instead inform the user and allow the user to fallback to browser implementation.
|
||||
**/
|
||||
it("should inform user if no credential exists when fallback is not supported", async () => {
|
||||
params.fallbackSupported = false;
|
||||
cipherService.getAllDecrypted.mockResolvedValue([]);
|
||||
userInterfaceSession.informCredentialNotFound.mockResolvedValue();
|
||||
|
||||
try {
|
||||
await authenticator.getAssertion(params, tab);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
|
||||
expect(userInterfaceSession.informCredentialNotFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should inform user if credential exists but rpId does not match", async () => {
|
||||
const cipher = await createCipherView({ type: CipherType.Login });
|
||||
cipher.login.fido2Credentials[0].credentialId = credentialId;
|
||||
cipher.login.fido2Credentials[0].rpId = "mismatch-rpid";
|
||||
cipherService.getAllDecrypted.mockResolvedValue([cipher]);
|
||||
userInterfaceSession.informCredentialNotFound.mockResolvedValue();
|
||||
|
||||
try {
|
||||
await authenticator.getAssertion(params, tab);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
|
||||
expect(userInterfaceSession.informCredentialNotFound).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("vault is missing discoverable credential", () => {
|
||||
let params: Fido2AuthenticatorGetAssertionParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
params = await createParams({
|
||||
allowCredentialDescriptorList: [],
|
||||
rpId: RpId,
|
||||
});
|
||||
cipherService.getAllDecrypted.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
/** Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error", async () => {
|
||||
const result = async () => await authenticator.getAssertion(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vault contains credential", () => {
|
||||
let credentialIds: string[];
|
||||
let ciphers: CipherView[];
|
||||
let params: Fido2AuthenticatorGetAssertionParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
credentialIds = [Utils.newGuid(), Utils.newGuid()];
|
||||
ciphers = [
|
||||
await createCipherView(
|
||||
{ type: CipherType.Login },
|
||||
{ credentialId: credentialIds[0], rpId: RpId, discoverable: false },
|
||||
),
|
||||
await createCipherView(
|
||||
{ type: CipherType.Login },
|
||||
{ credentialId: credentialIds[1], rpId: RpId, discoverable: true },
|
||||
),
|
||||
];
|
||||
params = await createParams({
|
||||
allowCredentialDescriptorList: credentialIds.map((credentialId) => ({
|
||||
id: guidToRawFormat(credentialId),
|
||||
type: "public-key",
|
||||
})),
|
||||
rpId: RpId,
|
||||
});
|
||||
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
||||
});
|
||||
|
||||
it("should ask for all credentials in list when `params` contains allowedCredentials list", async () => {
|
||||
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||
cipherId: ciphers[0].id,
|
||||
userVerified: false,
|
||||
});
|
||||
|
||||
await authenticator.getAssertion(params, tab);
|
||||
|
||||
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({
|
||||
cipherIds: ciphers.map((c) => c.id),
|
||||
userVerification: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should only ask for discoverable credentials matched by rpId when params does not contains allowedCredentials list", async () => {
|
||||
params.allowCredentialDescriptorList = undefined;
|
||||
const discoverableCiphers = ciphers.filter((c) => c.login.fido2Credentials[0].discoverable);
|
||||
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||
cipherId: discoverableCiphers[0].id,
|
||||
userVerified: false,
|
||||
});
|
||||
|
||||
await authenticator.getAssertion(params, tab);
|
||||
|
||||
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({
|
||||
cipherIds: [discoverableCiphers[0].id],
|
||||
userVerification: false,
|
||||
});
|
||||
});
|
||||
|
||||
for (const userVerification of [true, false]) {
|
||||
/** Spec: Prompt the user to select a public key credential source selectedCredential from credentialOptions. */
|
||||
it(`should request confirmation from user when user verification is ${userVerification}`, async () => {
|
||||
params.requireUserVerification = userVerification;
|
||||
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||
cipherId: ciphers[0].id,
|
||||
userVerified: userVerification,
|
||||
});
|
||||
|
||||
await authenticator.getAssertion(params, tab);
|
||||
|
||||
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({
|
||||
cipherIds: ciphers.map((c) => c.id),
|
||||
userVerification,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error", async () => {
|
||||
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||
cipherId: undefined,
|
||||
userVerified: false,
|
||||
});
|
||||
|
||||
const result = async () => await authenticator.getAssertion(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed);
|
||||
});
|
||||
|
||||
it("should throw error if user verification fails and cipher requires reprompt", async () => {
|
||||
ciphers[0].reprompt = CipherRepromptType.Password;
|
||||
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||
cipherId: ciphers[0].id,
|
||||
userVerified: false,
|
||||
});
|
||||
|
||||
const result = async () => await authenticator.getAssertion(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertion of credential", () => {
|
||||
let keyPair: CryptoKeyPair;
|
||||
let credentialIds: string[];
|
||||
let selectedCredentialId: string;
|
||||
let ciphers: CipherView[];
|
||||
let fido2Credentials: Fido2CredentialView[];
|
||||
let params: Fido2AuthenticatorGetAssertionParams;
|
||||
|
||||
const init = async () => {
|
||||
keyPair = await createKeyPair();
|
||||
credentialIds = [Utils.newGuid(), Utils.newGuid()];
|
||||
const keyValue = Fido2Utils.bufferToString(
|
||||
await crypto.subtle.exportKey("pkcs8", keyPair.privateKey),
|
||||
);
|
||||
ciphers = credentialIds.map((id) =>
|
||||
createCipherView(
|
||||
{ type: CipherType.Login },
|
||||
{ credentialId: id, rpId: RpId, counter: 9000, keyValue },
|
||||
),
|
||||
);
|
||||
fido2Credentials = ciphers.map((c) => c.login.fido2Credentials[0]);
|
||||
selectedCredentialId = credentialIds[0];
|
||||
params = await createParams({
|
||||
allowCredentialDescriptorList: credentialIds.map((credentialId) => ({
|
||||
id: guidToRawFormat(credentialId),
|
||||
type: "public-key",
|
||||
})),
|
||||
rpId: RpId,
|
||||
});
|
||||
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
||||
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||
cipherId: ciphers[0].id,
|
||||
userVerified: false,
|
||||
});
|
||||
};
|
||||
beforeEach(init);
|
||||
|
||||
/** Spec: Increment the credential associated signature counter */
|
||||
it("should increment counter and save to server when stored counter is larger than zero", async () => {
|
||||
const encrypted = Symbol();
|
||||
cipherService.encrypt.mockResolvedValue(encrypted as any);
|
||||
ciphers[0].login.fido2Credentials[0].counter = 9000;
|
||||
|
||||
await authenticator.getAssertion(params, tab);
|
||||
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted);
|
||||
expect(cipherService.encrypt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: ciphers[0].id,
|
||||
login: expect.objectContaining({
|
||||
fido2Credentials: [
|
||||
expect.objectContaining({
|
||||
counter: 9001,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/** Spec: Authenticators that do not implement a signature counter leave the signCount in the authenticator data constant at zero. */
|
||||
it("should not save to server when stored counter is zero", async () => {
|
||||
const encrypted = Symbol();
|
||||
cipherService.encrypt.mockResolvedValue(encrypted as any);
|
||||
ciphers[0].login.fido2Credentials[0].counter = 0;
|
||||
|
||||
await authenticator.getAssertion(params, tab);
|
||||
|
||||
expect(cipherService.updateWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return an assertion result", async () => {
|
||||
const result = await authenticator.getAssertion(params, tab);
|
||||
|
||||
const encAuthData = result.authenticatorData;
|
||||
const rpIdHash = encAuthData.slice(0, 32);
|
||||
const flags = encAuthData.slice(32, 33);
|
||||
const counter = encAuthData.slice(33, 37);
|
||||
|
||||
expect(result.selectedCredential.id).toEqual(guidToRawFormat(selectedCredentialId));
|
||||
expect(result.selectedCredential.userHandle).toEqual(
|
||||
Fido2Utils.stringToBuffer(fido2Credentials[0].userHandle),
|
||||
);
|
||||
expect(rpIdHash).toEqual(
|
||||
new Uint8Array([
|
||||
0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8,
|
||||
0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1,
|
||||
0xd0, 0x5c, 0x3d, 0xc3,
|
||||
]),
|
||||
);
|
||||
expect(flags).toEqual(new Uint8Array([0b00011001])); // UP = true, BE = true, BS = true
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // 9001 in hex
|
||||
|
||||
// Verify signature
|
||||
// TODO: Cannot verify signature because it has been converted into DER format
|
||||
// const sigBase = new Uint8Array([
|
||||
// ...result.authenticatorData,
|
||||
// ...Fido2Utils.bufferSourceToUint8Array(params.hash),
|
||||
// ]);
|
||||
// const isValidSignature = await crypto.subtle.verify(
|
||||
// { name: "ECDSA", hash: { name: "SHA-256" } },
|
||||
// keyPair.publicKey,
|
||||
// result.signature,
|
||||
// sigBase
|
||||
// );
|
||||
// expect(isValidSignature).toBe(true);
|
||||
});
|
||||
|
||||
it("should always generate unique signatures even if the input is the same", async () => {
|
||||
const signatures = new Set();
|
||||
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
await init(); // Reset inputs
|
||||
const result = await authenticator.getAssertion(params, tab);
|
||||
|
||||
const counter = result.authenticatorData.slice(33, 37);
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // double check that the counter doesn't change
|
||||
|
||||
const signature = Fido2Utils.bufferToString(result.signature);
|
||||
if (signatures.has(signature)) {
|
||||
throw new Error("Found duplicate signature");
|
||||
}
|
||||
signatures.add(signature);
|
||||
}
|
||||
});
|
||||
|
||||
/** Spec: If any error occurred while generating the assertion signature, return an error code equivalent to "UnknownError" and terminate the operation. */
|
||||
it("should throw unkown error if creation fails", async () => {
|
||||
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
const result = async () => await authenticator.getAssertion(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown);
|
||||
});
|
||||
});
|
||||
|
||||
async function createParams(
|
||||
params: Partial<Fido2AuthenticatorGetAssertionParams> = {},
|
||||
): Promise<Fido2AuthenticatorGetAssertionParams> {
|
||||
return {
|
||||
rpId: params.rpId ?? RpId,
|
||||
hash: params.hash ?? (await createClientDataHash()),
|
||||
allowCredentialDescriptorList: params.allowCredentialDescriptorList ?? [],
|
||||
requireUserVerification: params.requireUserVerification ?? false,
|
||||
extensions: params.extensions ?? {},
|
||||
fallbackSupported: params.fallbackSupported ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
type InvalidParams = Awaited<ReturnType<typeof createInvalidParams>>;
|
||||
async function createInvalidParams() {
|
||||
const emptyRpId = await createParams();
|
||||
emptyRpId.rpId = undefined as any;
|
||||
return {
|
||||
emptyRpId,
|
||||
invalidUv: await createParams({
|
||||
requireUserVerification: "invalid-value" as any,
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createCipherView(
|
||||
data: Partial<Omit<CipherView, "fido2Credential">> = {},
|
||||
fido2Credential: Partial<Fido2CredentialView> = {},
|
||||
): CipherView {
|
||||
const cipher = new CipherView();
|
||||
cipher.id = data.id ?? Utils.newGuid();
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.localData = {};
|
||||
|
||||
const fido2CredentialView = new Fido2CredentialView();
|
||||
fido2CredentialView.credentialId = fido2Credential.credentialId ?? Utils.newGuid();
|
||||
fido2CredentialView.rpId = fido2Credential.rpId ?? RpId;
|
||||
fido2CredentialView.counter = fido2Credential.counter ?? 0;
|
||||
fido2CredentialView.userHandle =
|
||||
fido2Credential.userHandle ?? Fido2Utils.bufferToString(randomBytes(16));
|
||||
fido2CredentialView.userName = fido2Credential.userName;
|
||||
fido2CredentialView.keyAlgorithm = fido2Credential.keyAlgorithm ?? "ECDSA";
|
||||
fido2CredentialView.keyCurve = fido2Credential.keyCurve ?? "P-256";
|
||||
fido2CredentialView.discoverable = fido2Credential.discoverable ?? true;
|
||||
fido2CredentialView.keyValue =
|
||||
fido2CredentialView.keyValue ??
|
||||
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTC-7XDZipXbaVBlnkjlBgO16ZmqBZWejK2iYo6lV0dehRANCAASOcM2WduNq1DriRYN7ZekvZz-bRhA-qNT4v0fbp5suUFJyWmgOQ0bybZcLXHaerK5Ep1JiSrQcewtQNgLtry7f";
|
||||
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.fido2Credentials = [fido2CredentialView];
|
||||
|
||||
return cipher;
|
||||
}
|
||||
|
||||
async function createClientDataHash() {
|
||||
const encoder = new TextEncoder();
|
||||
const clientData = encoder.encode(
|
||||
JSON.stringify({
|
||||
type: "webauthn.create",
|
||||
challenge: Fido2Utils.bufferToString(randomBytes(16)),
|
||||
origin: RpId,
|
||||
crossOrigin: false,
|
||||
}),
|
||||
);
|
||||
return await crypto.subtle.digest({ name: "SHA-256" }, clientData);
|
||||
}
|
||||
|
||||
/** This is a fake function that always returns the same byte sequence */
|
||||
function randomBytes(length: number) {
|
||||
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
|
||||
}
|
||||
|
||||
async function createKeyPair() {
|
||||
return await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "ECDSA",
|
||||
namedCurve: "P-256",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../../vault/enums/cipher-type";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
|
||||
import {
|
||||
Fido2AlgorithmIdentifier,
|
||||
Fido2AuthenticatorError,
|
||||
Fido2AuthenticatorErrorCode,
|
||||
Fido2AuthenticatorGetAssertionParams,
|
||||
Fido2AuthenticatorGetAssertionResult,
|
||||
Fido2AuthenticatorMakeCredentialResult,
|
||||
Fido2AuthenticatorMakeCredentialsParams,
|
||||
Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction,
|
||||
PublicKeyCredentialDescriptor,
|
||||
} from "../../abstractions/fido2/fido2-authenticator.service.abstraction";
|
||||
import { Fido2UserInterfaceService } from "../../abstractions/fido2/fido2-user-interface.service.abstraction";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { CBOR } from "./cbor";
|
||||
import { p1363ToDer } 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<Fido2AuthenticatorMakeCredentialResult> {
|
||||
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;
|
||||
let pubKeyDer: ArrayBuffer;
|
||||
const response = await userInterfaceSession.confirmNewCredential({
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
userVerification: params.requireUserVerification,
|
||||
rpId: params.rpEntity.id,
|
||||
});
|
||||
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();
|
||||
pubKeyDer = await crypto.subtle.exportKey("spki", keyPair.publicKey);
|
||||
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,
|
||||
publicKey: pubKeyDer,
|
||||
publicKeyAlgorithm: -7,
|
||||
};
|
||||
} finally {
|
||||
userInterfaceSession.close();
|
||||
}
|
||||
}
|
||||
|
||||
async getAssertion(
|
||||
params: Fido2AuthenticatorGetAssertionParams,
|
||||
tab: chrome.tabs.Tab,
|
||||
abortController?: AbortController,
|
||||
): Promise<Fido2AuthenticatorGetAssertionResult> {
|
||||
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.`,
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
if (selectedFido2Credential.counter > 0) {
|
||||
++selectedFido2Credential.counter;
|
||||
}
|
||||
|
||||
selectedCipher.localData = {
|
||||
...selectedCipher.localData,
|
||||
lastUsedDate: new Date().getTime(),
|
||||
};
|
||||
|
||||
if (selectedFido2Credential.counter > 0) {
|
||||
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<string[]> {
|
||||
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<CipherView[]> {
|
||||
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<CipherView[]> {
|
||||
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<Fido2CredentialView> {
|
||||
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.userName = params.userEntity.name;
|
||||
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<CryptoKey> {
|
||||
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<number> = [];
|
||||
|
||||
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,
|
||||
backupEligibility: true,
|
||||
backupState: true, // Credentials are always synced
|
||||
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<number> = [];
|
||||
|
||||
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 p1363_signature = new Uint8Array(
|
||||
await crypto.subtle.sign(
|
||||
{
|
||||
name: "ECDSA",
|
||||
hash: { name: "SHA-256" },
|
||||
},
|
||||
params.privateKey,
|
||||
sigBase,
|
||||
),
|
||||
);
|
||||
|
||||
const asn1Der_signature = p1363ToDer(p1363_signature);
|
||||
|
||||
return asn1Der_signature;
|
||||
}
|
||||
|
||||
interface Flags {
|
||||
extensionData: boolean;
|
||||
attestationData: boolean;
|
||||
backupEligibility: boolean;
|
||||
backupState: 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.backupEligibility) {
|
||||
flags |= 0b00001000;
|
||||
}
|
||||
|
||||
if (options.backupState) {
|
||||
flags |= 0b00010000;
|
||||
}
|
||||
|
||||
if (options.userVerification) {
|
||||
flags |= 0b00000100;
|
||||
}
|
||||
|
||||
if (options.userPresence) {
|
||||
flags |= 0b00000001;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||||
import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import {
|
||||
Fido2AuthenticatorError,
|
||||
Fido2AuthenticatorErrorCode,
|
||||
Fido2AuthenticatorGetAssertionResult,
|
||||
Fido2AuthenticatorMakeCredentialResult,
|
||||
} from "../../abstractions/fido2/fido2-authenticator.service.abstraction";
|
||||
import {
|
||||
AssertCredentialParams,
|
||||
CreateCredentialParams,
|
||||
FallbackRequestedError,
|
||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
|
||||
import { Fido2ClientService } from "./fido2-client.service";
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
import { guidToRawFormat } from "./guid-utils";
|
||||
|
||||
const RpId = "bitwarden.com";
|
||||
const Origin = "https://bitwarden.com";
|
||||
const VaultUrl = "https://vault.bitwarden.com";
|
||||
|
||||
describe("FidoAuthenticatorService", () => {
|
||||
let authenticator!: MockProxy<Fido2AuthenticatorService>;
|
||||
let configService!: MockProxy<ConfigService>;
|
||||
let authService!: MockProxy<AuthService>;
|
||||
let vaultSettingsService: MockProxy<VaultSettingsService>;
|
||||
let domainSettingsService: MockProxy<DomainSettingsService>;
|
||||
let client!: Fido2ClientService;
|
||||
let tab!: chrome.tabs.Tab;
|
||||
|
||||
beforeEach(async () => {
|
||||
authenticator = mock<Fido2AuthenticatorService>();
|
||||
configService = mock<ConfigService>();
|
||||
authService = mock<AuthService>();
|
||||
vaultSettingsService = mock<VaultSettingsService>();
|
||||
domainSettingsService = mock<DomainSettingsService>();
|
||||
|
||||
client = new Fido2ClientService(
|
||||
authenticator,
|
||||
configService,
|
||||
authService,
|
||||
vaultSettingsService,
|
||||
domainSettingsService,
|
||||
);
|
||||
configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any);
|
||||
vaultSettingsService.enablePasskeys$ = of(true);
|
||||
domainSettingsService.neverDomains$ = of({});
|
||||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
|
||||
});
|
||||
|
||||
describe("createCredential", () => {
|
||||
describe("input parameters validation", () => {
|
||||
// Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException.
|
||||
it("should throw error if sameOriginWithAncestors is false", async () => {
|
||||
const params = createParams({ sameOriginWithAncestors: false });
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "NotAllowedError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
// Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError.
|
||||
it("should throw error if user.id is too small", async () => {
|
||||
const params = createParams({ user: { id: "", displayName: "displayName", name: "name" } });
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
// Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError.
|
||||
it("should throw error if user.id is too large", async () => {
|
||||
const params = createParams({
|
||||
user: {
|
||||
id: "YWJzb2x1dGVseS13YXktd2F5LXRvby1sYXJnZS1iYXNlNjQtZW5jb2RlZC11c2VyLWlkLWJpbmFyeS1zZXF1ZW5jZQ",
|
||||
displayName: "displayName",
|
||||
name: "name",
|
||||
},
|
||||
});
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
// Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm.
|
||||
// Not sure how to check this, or if it matters.
|
||||
it.todo("should throw error if origin is an opaque origin");
|
||||
|
||||
// Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm.
|
||||
it("should throw error if origin is not a valid domain name", async () => {
|
||||
const params = createParams({
|
||||
origin: "invalid-domain-name",
|
||||
});
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "SecurityError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm.
|
||||
it("should throw error if rp.id is not valid for this origin", async () => {
|
||||
const params = createParams({
|
||||
origin: "https://passwordless.dev",
|
||||
rp: { id: "bitwarden.com", name: "Bitwarden" },
|
||||
});
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "SecurityError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
it("should fallback if origin hostname is found in neverDomains", async () => {
|
||||
const params = createParams({
|
||||
origin: "https://bitwarden.com",
|
||||
rp: { id: "bitwarden.com", name: "Bitwarden" },
|
||||
});
|
||||
domainSettingsService.neverDomains$ = of({ "bitwarden.com": null });
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrow(FallbackRequestedError);
|
||||
});
|
||||
|
||||
it("should throw error if origin is not an https domain", async () => {
|
||||
const params = createParams({
|
||||
origin: "http://passwordless.dev",
|
||||
rp: { id: "bitwarden.com", name: "Bitwarden" },
|
||||
});
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "SecurityError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
// Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm.
|
||||
it("should throw error if no support key algorithms were found", async () => {
|
||||
const params = createParams({
|
||||
pubKeyCredParams: [
|
||||
{ alg: -9001, type: "public-key" },
|
||||
{ alg: -7, type: "not-supported" as any },
|
||||
],
|
||||
});
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "NotSupportedError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aborting", () => {
|
||||
// Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm.
|
||||
it("should throw error if aborting using abort controller", async () => {
|
||||
const params = createParams({});
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
const result = async () => await client.createCredential(params, tab, abortController);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "AbortError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating a new credential", () => {
|
||||
it("should call authenticator.makeCredential", async () => {
|
||||
const params = createParams({
|
||||
authenticatorSelection: { residentKey: "required", userVerification: "required" },
|
||||
});
|
||||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||||
|
||||
await client.createCredential(params, tab);
|
||||
|
||||
expect(authenticator.makeCredential).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requireResidentKey: true,
|
||||
requireUserVerification: true,
|
||||
rpEntity: expect.objectContaining({
|
||||
id: RpId,
|
||||
}),
|
||||
userEntity: expect.objectContaining({
|
||||
displayName: params.user.displayName,
|
||||
}),
|
||||
}),
|
||||
tab,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return credProps.rk = true when creating a discoverable credential", async () => {
|
||||
const params = createParams({
|
||||
authenticatorSelection: { residentKey: "required", userVerification: "required" },
|
||||
extensions: { credProps: true },
|
||||
});
|
||||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||||
|
||||
const result = await client.createCredential(params, tab);
|
||||
|
||||
expect(result.extensions.credProps?.rk).toBe(true);
|
||||
});
|
||||
|
||||
it("should return credProps.rk = false when creating a non-discoverable credential", async () => {
|
||||
const params = createParams({
|
||||
authenticatorSelection: { residentKey: "discouraged", userVerification: "required" },
|
||||
extensions: { credProps: true },
|
||||
});
|
||||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||||
|
||||
const result = await client.createCredential(params, tab);
|
||||
|
||||
expect(result.extensions.credProps?.rk).toBe(false);
|
||||
});
|
||||
|
||||
it("should return credProps = undefiend when the extension is not requested", async () => {
|
||||
const params = createParams({
|
||||
authenticatorSelection: { residentKey: "required", userVerification: "required" },
|
||||
extensions: {},
|
||||
});
|
||||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||||
|
||||
const result = await client.createCredential(params, tab);
|
||||
|
||||
expect(result.extensions.credProps).toBeUndefined();
|
||||
});
|
||||
|
||||
// Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm.
|
||||
it("should throw error if authenticator throws InvalidState", async () => {
|
||||
const params = createParams();
|
||||
authenticator.makeCredential.mockRejectedValue(
|
||||
new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState),
|
||||
);
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "InvalidStateError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
// This keeps sensetive information form leaking
|
||||
it("should throw NotAllowedError if authenticator throws unknown error", async () => {
|
||||
const params = createParams();
|
||||
authenticator.makeCredential.mockRejectedValue(new Error("unknown error"));
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "NotAllowedError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
it("should throw FallbackRequestedError if passkeys state is not enabled", async () => {
|
||||
const params = createParams();
|
||||
vaultSettingsService.enablePasskeys$ = of(false);
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toThrow(FallbackRequestedError);
|
||||
});
|
||||
|
||||
it("should throw FallbackRequestedError if user is logged out", async () => {
|
||||
const params = createParams();
|
||||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toThrow(FallbackRequestedError);
|
||||
});
|
||||
|
||||
it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => {
|
||||
const params = createParams({ origin: VaultUrl });
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toThrow(FallbackRequestedError);
|
||||
});
|
||||
});
|
||||
|
||||
function createParams(params: Partial<CreateCredentialParams> = {}): CreateCredentialParams {
|
||||
return {
|
||||
origin: params.origin ?? "https://bitwarden.com",
|
||||
sameOriginWithAncestors: params.sameOriginWithAncestors ?? true,
|
||||
attestation: params.attestation,
|
||||
authenticatorSelection: params.authenticatorSelection,
|
||||
challenge: params.challenge ?? "MzItYnl0ZXMtYmFzZTY0LWVuY29kZS1jaGFsbGVuZ2U",
|
||||
excludeCredentials: params.excludeCredentials,
|
||||
extensions: params.extensions,
|
||||
pubKeyCredParams: params.pubKeyCredParams ?? [
|
||||
{
|
||||
alg: -7,
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
rp: params.rp ?? {
|
||||
id: RpId,
|
||||
name: "Bitwarden",
|
||||
},
|
||||
user: params.user ?? {
|
||||
id: "YmFzZTY0LWVuY29kZWQtdXNlci1pZA",
|
||||
displayName: "User Name",
|
||||
name: "name",
|
||||
},
|
||||
fallbackSupported: params.fallbackSupported ?? false,
|
||||
timeout: params.timeout,
|
||||
};
|
||||
}
|
||||
|
||||
function createAuthenticatorMakeResult(): Fido2AuthenticatorMakeCredentialResult {
|
||||
return {
|
||||
credentialId: guidToRawFormat(Utils.newGuid()),
|
||||
attestationObject: randomBytes(128),
|
||||
authData: randomBytes(64),
|
||||
publicKey: randomBytes(64),
|
||||
publicKeyAlgorithm: -7,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
describe("assertCredential", () => {
|
||||
describe("invalid params", () => {
|
||||
// Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm.
|
||||
// Not sure how to check this, or if it matters.
|
||||
it.todo("should throw error if origin is an opaque origin");
|
||||
|
||||
// Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm.
|
||||
it("should throw error if origin is not a valid domain name", async () => {
|
||||
const params = createParams({
|
||||
origin: "invalid-domain-name",
|
||||
});
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "SecurityError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm.
|
||||
it("should throw error if rp.id is not valid for this origin", async () => {
|
||||
const params = createParams({
|
||||
origin: "https://passwordless.dev",
|
||||
rpId: "bitwarden.com",
|
||||
});
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "SecurityError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
it("should fallback if origin hostname is found in neverDomains", async () => {
|
||||
const params = createParams({
|
||||
origin: "https://bitwarden.com",
|
||||
});
|
||||
|
||||
domainSettingsService.neverDomains$ = of({ "bitwarden.com": null });
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
await expect(result).rejects.toThrow(FallbackRequestedError);
|
||||
});
|
||||
|
||||
it("should throw error if origin is not an http domain", async () => {
|
||||
const params = createParams({
|
||||
origin: "http://passwordless.dev",
|
||||
rpId: "bitwarden.com",
|
||||
});
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "SecurityError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aborting", () => {
|
||||
// Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm.
|
||||
it("should throw error if aborting using abort controller", async () => {
|
||||
const params = createParams({});
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab, abortController);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "AbortError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assert credential", () => {
|
||||
// Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm.
|
||||
it("should throw error if authenticator throws InvalidState", async () => {
|
||||
const params = createParams();
|
||||
authenticator.getAssertion.mockRejectedValue(
|
||||
new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState),
|
||||
);
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "InvalidStateError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
// This keeps sensetive information form leaking
|
||||
it("should throw NotAllowedError if authenticator throws unknown error", async () => {
|
||||
const params = createParams();
|
||||
authenticator.getAssertion.mockRejectedValue(new Error("unknown error"));
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toMatchObject({ name: "NotAllowedError" });
|
||||
await rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
|
||||
it("should throw FallbackRequestedError if passkeys state is not enabled", async () => {
|
||||
const params = createParams();
|
||||
vaultSettingsService.enablePasskeys$ = of(false);
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toThrow(FallbackRequestedError);
|
||||
});
|
||||
|
||||
it("should throw FallbackRequestedError if user is logged out", async () => {
|
||||
const params = createParams();
|
||||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toThrow(FallbackRequestedError);
|
||||
});
|
||||
|
||||
it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => {
|
||||
const params = createParams({ origin: VaultUrl });
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
const rejects = expect(result).rejects;
|
||||
await rejects.toThrow(FallbackRequestedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assert non-discoverable credential", () => {
|
||||
it("should call authenticator.assertCredential", async () => {
|
||||
const allowedCredentialIds = [
|
||||
Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())),
|
||||
Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())),
|
||||
Fido2Utils.bufferToString(Utils.fromByteStringToArray("not-a-guid")),
|
||||
];
|
||||
const params = createParams({
|
||||
userVerification: "required",
|
||||
allowedCredentialIds,
|
||||
});
|
||||
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
|
||||
|
||||
await client.assertCredential(params, tab);
|
||||
|
||||
expect(authenticator.getAssertion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requireUserVerification: true,
|
||||
rpId: RpId,
|
||||
allowCredentialDescriptorList: [
|
||||
expect.objectContaining({
|
||||
id: Fido2Utils.stringToBuffer(allowedCredentialIds[0]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: Fido2Utils.stringToBuffer(allowedCredentialIds[1]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: Fido2Utils.stringToBuffer(allowedCredentialIds[2]),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
tab,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assert discoverable credential", () => {
|
||||
it("should call authenticator.assertCredential", async () => {
|
||||
const params = createParams({
|
||||
userVerification: "required",
|
||||
allowedCredentialIds: [],
|
||||
});
|
||||
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
|
||||
|
||||
await client.assertCredential(params, tab);
|
||||
|
||||
expect(authenticator.getAssertion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requireUserVerification: true,
|
||||
rpId: RpId,
|
||||
allowCredentialDescriptorList: [],
|
||||
}),
|
||||
tab,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createParams(params: Partial<AssertCredentialParams> = {}): AssertCredentialParams {
|
||||
return {
|
||||
allowedCredentialIds: params.allowedCredentialIds ?? [],
|
||||
challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)),
|
||||
origin: params.origin ?? Origin,
|
||||
rpId: params.rpId ?? RpId,
|
||||
timeout: params.timeout,
|
||||
userVerification: params.userVerification,
|
||||
sameOriginWithAncestors: true,
|
||||
fallbackSupported: params.fallbackSupported ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function createAuthenticatorAssertResult(): Fido2AuthenticatorGetAssertionResult {
|
||||
return {
|
||||
selectedCredential: {
|
||||
id: randomBytes(32),
|
||||
userHandle: randomBytes(32),
|
||||
},
|
||||
authenticatorData: randomBytes(64),
|
||||
signature: randomBytes(64),
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** This is a fake function that always returns the same byte sequence */
|
||||
function randomBytes(length: number) {
|
||||
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
|
||||
}
|
||||
438
libs/common/src/platform/services/fido2/fido2-client.service.ts
Normal file
438
libs/common/src/platform/services/fido2/fido2-client.service.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { parse } from "tldts";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||||
import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import {
|
||||
Fido2AuthenticatorError,
|
||||
Fido2AuthenticatorErrorCode,
|
||||
Fido2AuthenticatorGetAssertionParams,
|
||||
Fido2AuthenticatorMakeCredentialsParams,
|
||||
Fido2AuthenticatorService,
|
||||
PublicKeyCredentialDescriptor,
|
||||
} from "../../abstractions/fido2/fido2-authenticator.service.abstraction";
|
||||
import {
|
||||
AssertCredentialParams,
|
||||
AssertCredentialResult,
|
||||
CreateCredentialParams,
|
||||
CreateCredentialResult,
|
||||
FallbackRequestedError,
|
||||
Fido2ClientService as Fido2ClientServiceAbstraction,
|
||||
PublicKeyCredentialParam,
|
||||
UserRequestedFallbackAbortReason,
|
||||
UserVerification,
|
||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { isValidRpId } from "./domain-utils";
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
|
||||
/**
|
||||
* Bitwarden implementation of the Web Authentication API as described by W3C
|
||||
* https://www.w3.org/TR/webauthn-3/#sctn-api
|
||||
*
|
||||
* It is highly recommended that the W3C specification is used a reference when reading this code.
|
||||
*/
|
||||
export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||
constructor(
|
||||
private authenticator: Fido2AuthenticatorService,
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
private vaultSettingsService: VaultSettingsService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private logService?: LogService,
|
||||
) {}
|
||||
|
||||
async isFido2FeatureEnabled(hostname: string, origin: string): Promise<boolean> {
|
||||
const isUserLoggedIn =
|
||||
(await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut;
|
||||
if (!isUserLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const neverDomains = await firstValueFrom(this.domainSettingsService.neverDomains$);
|
||||
|
||||
const isExcludedDomain = neverDomains != null && hostname in neverDomains;
|
||||
if (isExcludedDomain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverConfig = await firstValueFrom(this.configService.serverConfig$);
|
||||
const isOriginEqualBitwardenVault = origin === serverConfig.environment?.vault;
|
||||
if (isOriginEqualBitwardenVault) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
|
||||
}
|
||||
|
||||
async createCredential(
|
||||
params: CreateCredentialParams,
|
||||
tab: chrome.tabs.Tab,
|
||||
abortController = new AbortController(),
|
||||
): Promise<CreateCredentialResult> {
|
||||
const parsedOrigin = parse(params.origin, { allowPrivateDomains: true });
|
||||
|
||||
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(
|
||||
parsedOrigin.hostname,
|
||||
params.origin,
|
||||
);
|
||||
|
||||
if (!enableFido2VaultCredentials) {
|
||||
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
|
||||
throw new FallbackRequestedError();
|
||||
}
|
||||
|
||||
if (!params.sameOriginWithAncestors) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] Invalid 'sameOriginWithAncestors' value: ${params.sameOriginWithAncestors}`,
|
||||
);
|
||||
throw new DOMException("Invalid 'sameOriginWithAncestors' value", "NotAllowedError");
|
||||
}
|
||||
|
||||
const userId = Fido2Utils.stringToBuffer(params.user.id);
|
||||
if (userId.length < 1 || userId.length > 64) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.length})`,
|
||||
);
|
||||
throw new TypeError("Invalid 'user.id' length");
|
||||
}
|
||||
|
||||
params.rp.id = params.rp.id ?? parsedOrigin.hostname;
|
||||
if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) {
|
||||
this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`);
|
||||
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
|
||||
}
|
||||
|
||||
if (!isValidRpId(params.rp.id, params.origin)) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}`,
|
||||
);
|
||||
throw new DOMException("'rp.id' cannot be used with the current origin", "SecurityError");
|
||||
}
|
||||
|
||||
let credTypesAndPubKeyAlgs: PublicKeyCredentialParam[];
|
||||
if (params.pubKeyCredParams?.length > 0) {
|
||||
// Filter out all unsupported algorithms
|
||||
credTypesAndPubKeyAlgs = params.pubKeyCredParams.filter(
|
||||
(kp) => kp.alg === -7 && kp.type === "public-key",
|
||||
);
|
||||
} else {
|
||||
// Assign default algorithms
|
||||
credTypesAndPubKeyAlgs = [
|
||||
{ alg: -7, type: "public-key" },
|
||||
{ alg: -257, type: "public-key" },
|
||||
];
|
||||
}
|
||||
|
||||
if (credTypesAndPubKeyAlgs.length === 0) {
|
||||
const requestedAlgorithms = credTypesAndPubKeyAlgs.map((p) => p.alg).join(", ");
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] No compatible algorithms found, RP requested: ${requestedAlgorithms}`,
|
||||
);
|
||||
throw new DOMException("No supported key algorithms were found", "NotSupportedError");
|
||||
}
|
||||
|
||||
const collectedClientData = {
|
||||
type: "webauthn.create",
|
||||
challenge: params.challenge,
|
||||
origin: params.origin,
|
||||
crossOrigin: !params.sameOriginWithAncestors,
|
||||
// tokenBinding: {} // Not currently supported
|
||||
};
|
||||
const clientDataJSON = JSON.stringify(collectedClientData);
|
||||
const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON);
|
||||
const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes);
|
||||
const makeCredentialParams = mapToMakeCredentialParams({
|
||||
params,
|
||||
credTypesAndPubKeyAlgs,
|
||||
clientDataHash,
|
||||
});
|
||||
|
||||
// Set timeout before invoking authenticator
|
||||
if (abortController.signal.aborted) {
|
||||
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
||||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||
}
|
||||
const timeout = setAbortTimeout(
|
||||
abortController,
|
||||
params.authenticatorSelection?.userVerification,
|
||||
params.timeout,
|
||||
);
|
||||
|
||||
let makeCredentialResult;
|
||||
try {
|
||||
makeCredentialResult = await this.authenticator.makeCredential(
|
||||
makeCredentialParams,
|
||||
tab,
|
||||
abortController,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
abortController.signal.aborted &&
|
||||
abortController.signal.reason === UserRequestedFallbackAbortReason
|
||||
) {
|
||||
this.logService?.info(`[Fido2Client] Aborting because user requested fallback`);
|
||||
throw new FallbackRequestedError();
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof Fido2AuthenticatorError &&
|
||||
error.errorCode === Fido2AuthenticatorErrorCode.InvalidState
|
||||
) {
|
||||
this.logService?.warning(`[Fido2Client] Unknown error: ${error}`);
|
||||
throw new DOMException("Unknown error occured.", "InvalidStateError");
|
||||
}
|
||||
|
||||
this.logService?.info(`[Fido2Client] Aborted by user: ${error}`);
|
||||
throw new DOMException(
|
||||
"The operation either timed out or was not allowed.",
|
||||
"NotAllowedError",
|
||||
);
|
||||
}
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
||||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||
}
|
||||
|
||||
let credProps;
|
||||
if (params.extensions?.credProps) {
|
||||
credProps = {
|
||||
rk: makeCredentialParams.requireResidentKey,
|
||||
};
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
return {
|
||||
credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId),
|
||||
attestationObject: Fido2Utils.bufferToString(makeCredentialResult.attestationObject),
|
||||
authData: Fido2Utils.bufferToString(makeCredentialResult.authData),
|
||||
clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes),
|
||||
publicKey: Fido2Utils.bufferToString(makeCredentialResult.publicKey),
|
||||
publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm,
|
||||
transports: params.rp.id === "google.com" ? ["internal", "usb"] : ["internal"],
|
||||
extensions: { credProps },
|
||||
};
|
||||
}
|
||||
|
||||
async assertCredential(
|
||||
params: AssertCredentialParams,
|
||||
tab: chrome.tabs.Tab,
|
||||
abortController = new AbortController(),
|
||||
): Promise<AssertCredentialResult> {
|
||||
const parsedOrigin = parse(params.origin, { allowPrivateDomains: true });
|
||||
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(
|
||||
parsedOrigin.hostname,
|
||||
params.origin,
|
||||
);
|
||||
|
||||
if (!enableFido2VaultCredentials) {
|
||||
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
|
||||
throw new FallbackRequestedError();
|
||||
}
|
||||
|
||||
params.rpId = params.rpId ?? parsedOrigin.hostname;
|
||||
|
||||
if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) {
|
||||
this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`);
|
||||
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
|
||||
}
|
||||
|
||||
if (!isValidRpId(params.rpId, params.origin)) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}`,
|
||||
);
|
||||
throw new DOMException("'rp.id' cannot be used with the current origin", "SecurityError");
|
||||
}
|
||||
|
||||
const collectedClientData = {
|
||||
type: "webauthn.get",
|
||||
challenge: params.challenge,
|
||||
origin: params.origin,
|
||||
crossOrigin: !params.sameOriginWithAncestors,
|
||||
// tokenBinding: {} // Not currently supported
|
||||
};
|
||||
const clientDataJSON = JSON.stringify(collectedClientData);
|
||||
const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON);
|
||||
const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes);
|
||||
const getAssertionParams = mapToGetAssertionParams({ params, clientDataHash });
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
||||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||
}
|
||||
|
||||
const timeout = setAbortTimeout(abortController, params.userVerification, params.timeout);
|
||||
|
||||
let getAssertionResult;
|
||||
try {
|
||||
getAssertionResult = await this.authenticator.getAssertion(
|
||||
getAssertionParams,
|
||||
tab,
|
||||
abortController,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
abortController.signal.aborted &&
|
||||
abortController.signal.reason === UserRequestedFallbackAbortReason
|
||||
) {
|
||||
this.logService?.info(`[Fido2Client] Aborting because user requested fallback`);
|
||||
throw new FallbackRequestedError();
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof Fido2AuthenticatorError &&
|
||||
error.errorCode === Fido2AuthenticatorErrorCode.InvalidState
|
||||
) {
|
||||
this.logService?.warning(`[Fido2Client] Unknown error: ${error}`);
|
||||
throw new DOMException("Unknown error occured.", "InvalidStateError");
|
||||
}
|
||||
|
||||
this.logService?.info(`[Fido2Client] Aborted by user: ${error}`);
|
||||
throw new DOMException(
|
||||
"The operation either timed out or was not allowed.",
|
||||
"NotAllowedError",
|
||||
);
|
||||
}
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
||||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
return {
|
||||
authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData),
|
||||
clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes),
|
||||
credentialId: Fido2Utils.bufferToString(getAssertionResult.selectedCredential.id),
|
||||
userHandle:
|
||||
getAssertionResult.selectedCredential.userHandle !== undefined
|
||||
? Fido2Utils.bufferToString(getAssertionResult.selectedCredential.userHandle)
|
||||
: undefined,
|
||||
signature: Fido2Utils.bufferToString(getAssertionResult.signature),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TIMEOUTS = {
|
||||
NO_VERIFICATION: {
|
||||
DEFAULT: 120000,
|
||||
MIN: 30000,
|
||||
MAX: 180000,
|
||||
},
|
||||
WITH_VERIFICATION: {
|
||||
DEFAULT: 300000,
|
||||
MIN: 30000,
|
||||
MAX: 600000,
|
||||
},
|
||||
};
|
||||
|
||||
function setAbortTimeout(
|
||||
abortController: AbortController,
|
||||
userVerification?: UserVerification,
|
||||
timeout?: number,
|
||||
): number {
|
||||
let clampedTimeout: number;
|
||||
|
||||
if (userVerification === "required") {
|
||||
timeout = timeout ?? TIMEOUTS.WITH_VERIFICATION.DEFAULT;
|
||||
clampedTimeout = Math.max(
|
||||
TIMEOUTS.WITH_VERIFICATION.MIN,
|
||||
Math.min(timeout, TIMEOUTS.WITH_VERIFICATION.MAX),
|
||||
);
|
||||
} else {
|
||||
timeout = timeout ?? TIMEOUTS.NO_VERIFICATION.DEFAULT;
|
||||
clampedTimeout = Math.max(
|
||||
TIMEOUTS.NO_VERIFICATION.MIN,
|
||||
Math.min(timeout, TIMEOUTS.NO_VERIFICATION.MAX),
|
||||
);
|
||||
}
|
||||
|
||||
return self.setTimeout(() => abortController.abort(), clampedTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert data gathered by the WebAuthn Client to a format that can be used by the authenticator.
|
||||
*/
|
||||
function mapToMakeCredentialParams({
|
||||
params,
|
||||
credTypesAndPubKeyAlgs,
|
||||
clientDataHash,
|
||||
}: {
|
||||
params: CreateCredentialParams;
|
||||
credTypesAndPubKeyAlgs: PublicKeyCredentialParam[];
|
||||
clientDataHash: ArrayBuffer;
|
||||
}): Fido2AuthenticatorMakeCredentialsParams {
|
||||
const excludeCredentialDescriptorList: PublicKeyCredentialDescriptor[] =
|
||||
params.excludeCredentials?.map((credential) => ({
|
||||
id: Fido2Utils.stringToBuffer(credential.id),
|
||||
transports: credential.transports,
|
||||
type: credential.type,
|
||||
})) ?? [];
|
||||
|
||||
const requireResidentKey =
|
||||
params.authenticatorSelection?.residentKey === "required" ||
|
||||
params.authenticatorSelection?.residentKey === "preferred" ||
|
||||
(params.authenticatorSelection?.residentKey === undefined &&
|
||||
params.authenticatorSelection?.requireResidentKey === true);
|
||||
|
||||
const requireUserVerification =
|
||||
params.authenticatorSelection?.userVerification === "required" ||
|
||||
params.authenticatorSelection?.userVerification === "preferred" ||
|
||||
params.authenticatorSelection?.userVerification === undefined;
|
||||
|
||||
return {
|
||||
requireResidentKey,
|
||||
requireUserVerification,
|
||||
enterpriseAttestationPossible: params.attestation === "enterprise",
|
||||
excludeCredentialDescriptorList,
|
||||
credTypesAndPubKeyAlgs,
|
||||
hash: clientDataHash,
|
||||
rpEntity: {
|
||||
id: params.rp.id,
|
||||
name: params.rp.name,
|
||||
},
|
||||
userEntity: {
|
||||
id: Fido2Utils.stringToBuffer(params.user.id),
|
||||
displayName: params.user.displayName,
|
||||
name: params.user.name,
|
||||
},
|
||||
fallbackSupported: params.fallbackSupported,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert data gathered by the WebAuthn Client to a format that can be used by the authenticator.
|
||||
*/
|
||||
function mapToGetAssertionParams({
|
||||
params,
|
||||
clientDataHash,
|
||||
}: {
|
||||
params: AssertCredentialParams;
|
||||
clientDataHash: ArrayBuffer;
|
||||
}): Fido2AuthenticatorGetAssertionParams {
|
||||
const allowCredentialDescriptorList: PublicKeyCredentialDescriptor[] =
|
||||
params.allowedCredentialIds.map((id) => ({
|
||||
id: Fido2Utils.stringToBuffer(id),
|
||||
type: "public-key",
|
||||
}));
|
||||
|
||||
const requireUserVerification =
|
||||
params.userVerification === "required" ||
|
||||
params.userVerification === "preferred" ||
|
||||
params.userVerification === undefined;
|
||||
|
||||
return {
|
||||
rpId: params.rpId,
|
||||
requireUserVerification,
|
||||
hash: clientDataHash,
|
||||
allowCredentialDescriptorList,
|
||||
extensions: {},
|
||||
fallbackSupported: params.fallbackSupported,
|
||||
};
|
||||
}
|
||||
40
libs/common/src/platform/services/fido2/fido2-utils.spec.ts
Normal file
40
libs/common/src/platform/services/fido2/fido2-utils.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
|
||||
describe("Fido2 Utils", () => {
|
||||
const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
|
||||
const b64HelloWorldString = "aGVsbG8gd29ybGQ=";
|
||||
|
||||
describe("fromBufferToB64(...)", () => {
|
||||
it("should convert an ArrayBuffer to a b64 string", () => {
|
||||
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
|
||||
const b64String = Fido2Utils.fromBufferToB64(buffer);
|
||||
expect(b64String).toBe(b64HelloWorldString);
|
||||
});
|
||||
|
||||
it("should return an empty string when given an empty ArrayBuffer", () => {
|
||||
const buffer = new Uint8Array([]).buffer;
|
||||
const b64String = Fido2Utils.fromBufferToB64(buffer);
|
||||
expect(b64String).toBe("");
|
||||
});
|
||||
|
||||
it("should return null when given null input", () => {
|
||||
const b64String = Fido2Utils.fromBufferToB64(null);
|
||||
expect(b64String).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromB64ToArray(...)", () => {
|
||||
it("should convert a b64 string to an Uint8Array", () => {
|
||||
const expectedArray = new Uint8Array(asciiHelloWorldArray);
|
||||
|
||||
const resultArray = Fido2Utils.fromB64ToArray(b64HelloWorldString);
|
||||
|
||||
expect(resultArray).toEqual(expectedArray);
|
||||
});
|
||||
|
||||
it("should return null when given null input", () => {
|
||||
const expectedArray = Fido2Utils.fromB64ToArray(null);
|
||||
expect(expectedArray).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
80
libs/common/src/platform/services/fido2/fido2-utils.ts
Normal file
80
libs/common/src/platform/services/fido2/fido2-utils.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export class Fido2Utils {
|
||||
static bufferToString(bufferSource: BufferSource): string {
|
||||
let buffer: Uint8Array;
|
||||
if (bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined) {
|
||||
buffer = new Uint8Array(bufferSource as ArrayBuffer);
|
||||
} else {
|
||||
buffer = new Uint8Array(bufferSource.buffer);
|
||||
}
|
||||
|
||||
return Fido2Utils.fromBufferToB64(buffer)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
static stringToBuffer(str: string): Uint8Array {
|
||||
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str));
|
||||
}
|
||||
|
||||
static bufferSourceToUint8Array(bufferSource: BufferSource) {
|
||||
if (Fido2Utils.isArrayBuffer(bufferSource)) {
|
||||
return new Uint8Array(bufferSource);
|
||||
} else {
|
||||
return new Uint8Array(bufferSource.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility function to identify type of bufferSource. Necessary because of differences between runtimes */
|
||||
private static isArrayBuffer(bufferSource: BufferSource): bufferSource is ArrayBuffer {
|
||||
return bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined;
|
||||
}
|
||||
|
||||
static fromB64toUrlB64(b64Str: string) {
|
||||
return b64Str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
static fromBufferToB64(buffer: ArrayBuffer): string {
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return globalThis.btoa(binary);
|
||||
}
|
||||
|
||||
static fromB64ToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binaryString = globalThis.atob(str);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static fromUrlB64ToB64(urlB64Str: string): string {
|
||||
let output = urlB64Str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
switch (output.length % 4) {
|
||||
case 0:
|
||||
break;
|
||||
case 2:
|
||||
output += "==";
|
||||
break;
|
||||
case 3:
|
||||
output += "=";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Illegal base64url string!");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
95
libs/common/src/platform/services/fido2/guid-utils.ts
Normal file
95
libs/common/src/platform/services/fido2/guid-utils.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
License for: guidToRawFormat, guidToStandardFormat
|
||||
Source: https://github.com/uuidjs/uuid/
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2010-2020 Robert Kieffer and other contributors
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.*/
|
||||
|
||||
/** Private array used for optimization */
|
||||
const byteToHex = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).substring(1));
|
||||
|
||||
/** Convert standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID to raw 16 byte array. */
|
||||
export function guidToRawFormat(guid: string) {
|
||||
if (!isValidGuid(guid)) {
|
||||
throw TypeError("GUID parameter is invalid");
|
||||
}
|
||||
|
||||
let v;
|
||||
const arr = new Uint8Array(16);
|
||||
|
||||
// Parse ########-....-....-....-............
|
||||
arr[0] = (v = parseInt(guid.slice(0, 8), 16)) >>> 24;
|
||||
arr[1] = (v >>> 16) & 0xff;
|
||||
arr[2] = (v >>> 8) & 0xff;
|
||||
arr[3] = v & 0xff;
|
||||
|
||||
// Parse ........-####-....-....-............
|
||||
arr[4] = (v = parseInt(guid.slice(9, 13), 16)) >>> 8;
|
||||
arr[5] = v & 0xff;
|
||||
|
||||
// Parse ........-....-####-....-............
|
||||
arr[6] = (v = parseInt(guid.slice(14, 18), 16)) >>> 8;
|
||||
arr[7] = v & 0xff;
|
||||
|
||||
// Parse ........-....-....-####-............
|
||||
arr[8] = (v = parseInt(guid.slice(19, 23), 16)) >>> 8;
|
||||
arr[9] = v & 0xff;
|
||||
|
||||
// Parse ........-....-....-....-############
|
||||
// (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes)
|
||||
arr[10] = ((v = parseInt(guid.slice(24, 36), 16)) / 0x10000000000) & 0xff;
|
||||
arr[11] = (v / 0x100000000) & 0xff;
|
||||
arr[12] = (v >>> 24) & 0xff;
|
||||
arr[13] = (v >>> 16) & 0xff;
|
||||
arr[14] = (v >>> 8) & 0xff;
|
||||
arr[15] = v & 0xff;
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */
|
||||
export function guidToStandardFormat(bufferSource: BufferSource) {
|
||||
const arr =
|
||||
bufferSource instanceof ArrayBuffer
|
||||
? new Uint8Array(bufferSource)
|
||||
: new Uint8Array(bufferSource.buffer);
|
||||
// Note: Be careful editing this code! It's been tuned for performance
|
||||
// and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434
|
||||
const guid = (
|
||||
byteToHex[arr[0]] +
|
||||
byteToHex[arr[1]] +
|
||||
byteToHex[arr[2]] +
|
||||
byteToHex[arr[3]] +
|
||||
"-" +
|
||||
byteToHex[arr[4]] +
|
||||
byteToHex[arr[5]] +
|
||||
"-" +
|
||||
byteToHex[arr[6]] +
|
||||
byteToHex[arr[7]] +
|
||||
"-" +
|
||||
byteToHex[arr[8]] +
|
||||
byteToHex[arr[9]] +
|
||||
"-" +
|
||||
byteToHex[arr[10]] +
|
||||
byteToHex[arr[11]] +
|
||||
byteToHex[arr[12]] +
|
||||
byteToHex[arr[13]] +
|
||||
byteToHex[arr[14]] +
|
||||
byteToHex[arr[15]]
|
||||
).toLowerCase();
|
||||
|
||||
// Consistency check for valid UUID. If this throws, it's likely due to one
|
||||
// or more input array values not mapping to a hex octet (leading to "undefined" in the uuid)
|
||||
if (!isValidGuid(guid)) {
|
||||
throw TypeError("Converted GUID is invalid");
|
||||
}
|
||||
|
||||
return guid;
|
||||
}
|
||||
|
||||
// Perform format validation, without enforcing any variant restrictions as Utils.isGuid does
|
||||
function isValidGuid(guid: string): boolean {
|
||||
return RegExp(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/, "i").test(guid);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
|
||||
Fido2UserInterfaceSession,
|
||||
} from "../../abstractions/fido2/fido2-user-interface.service.abstraction";
|
||||
|
||||
/**
|
||||
* Noop implementation of the {@link Fido2UserInterfaceService}.
|
||||
* This implementation does not provide any user interface.
|
||||
*/
|
||||
export class Fido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction {
|
||||
newSession(): Promise<Fido2UserInterfaceSession> {
|
||||
throw new Error("Not implemented exception");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user