mirror of
https://github.com/bitwarden/browser
synced 2025-12-24 04:04:24 +00:00
Merge branch 'main' into auth/pm-8111/browser-refresh-login-component
This commit is contained in:
@@ -7,8 +7,6 @@ export class PermissionsApi extends BaseResponse {
|
||||
createNewCollections: boolean;
|
||||
editAnyCollection: boolean;
|
||||
deleteAnyCollection: boolean;
|
||||
editAssignedCollections: boolean;
|
||||
deleteAssignedCollections: boolean;
|
||||
manageCiphers: boolean;
|
||||
manageGroups: boolean;
|
||||
manageSso: boolean;
|
||||
@@ -29,8 +27,6 @@ export class PermissionsApi extends BaseResponse {
|
||||
this.createNewCollections = this.getResponseProperty("CreateNewCollections");
|
||||
this.editAnyCollection = this.getResponseProperty("EditAnyCollection");
|
||||
this.deleteAnyCollection = this.getResponseProperty("DeleteAnyCollection");
|
||||
this.editAssignedCollections = this.getResponseProperty("EditAssignedCollections");
|
||||
this.deleteAssignedCollections = this.getResponseProperty("DeleteAssignedCollections");
|
||||
|
||||
this.manageCiphers = this.getResponseProperty("ManageCiphers");
|
||||
this.manageGroups = this.getResponseProperty("ManageGroups");
|
||||
|
||||
@@ -2,7 +2,6 @@ import { firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
|
||||
import { FeatureFlag } from "../../enums/feature-flag.enum";
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
|
||||
@@ -334,9 +333,6 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
}
|
||||
|
||||
async recordDeviceTrustLoss(): Promise<void> {
|
||||
if (!(await this.configService.getFeatureFlag(FeatureFlag.DeviceTrustLogging))) {
|
||||
return;
|
||||
}
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
await this.devicesApiService.postDeviceTrustLoss(deviceIdentifier);
|
||||
}
|
||||
|
||||
@@ -340,8 +340,6 @@ describe("KeyConnectorService", () => {
|
||||
createNewCollections: false,
|
||||
editAnyCollection: false,
|
||||
deleteAnyCollection: false,
|
||||
editAssignedCollections: false,
|
||||
deleteAssignedCollections: false,
|
||||
manageGroups: false,
|
||||
managePolicies: false,
|
||||
manageSso: false,
|
||||
|
||||
@@ -107,3 +107,7 @@ export const ExtensionCommand = {
|
||||
export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand];
|
||||
|
||||
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute
|
||||
|
||||
export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4;
|
||||
|
||||
export * from "./match-patterns";
|
||||
|
||||
26
libs/common/src/autofill/constants/match-patterns.ts
Normal file
26
libs/common/src/autofill/constants/match-patterns.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "];
|
||||
|
||||
// `CardExpiryDateDelimiters` is not intended solely for regex consumption,
|
||||
// so we need to format it here
|
||||
export const ExpiryDateDelimitersPattern =
|
||||
"\\" +
|
||||
CardExpiryDateDelimiters.join("\\")
|
||||
// replace space character with the regex whitespace character class
|
||||
.replace(" ", "s");
|
||||
|
||||
export const MonthPattern = "(([1]{1}[0-2]{1})|(0?[1-9]{1}))";
|
||||
|
||||
// Because we're dealing with expiry dates, we assume the year will be in current or next century (as of 2024)
|
||||
export const ExpiryFullYearPattern = "2[0-1]{1}\\d{2}";
|
||||
|
||||
export const DelimiterPatternExpression = new RegExp(`[${ExpiryDateDelimitersPattern}]`, "g");
|
||||
|
||||
export const IrrelevantExpiryCharactersPatternExpression = new RegExp(
|
||||
// "nor digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w
|
||||
`[^\\d${ExpiryDateDelimitersPattern}]`,
|
||||
"g",
|
||||
);
|
||||
|
||||
export const MonthPatternExpression = new RegExp(`^${MonthPattern}$`);
|
||||
|
||||
export const ExpiryFullYearPatternExpression = new RegExp(`^${ExpiryFullYearPattern}$`);
|
||||
284
libs/common/src/autofill/utils.spec.ts
Normal file
284
libs/common/src/autofill/utils.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
normalizeExpiryYearFormat,
|
||||
isCardExpired,
|
||||
parseYearMonthExpiry,
|
||||
} from "@bitwarden/common/autofill/utils";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
|
||||
function getExpiryYearValueFormats(currentCentury: string) {
|
||||
return [
|
||||
[-12, `${currentCentury}12`],
|
||||
[0, `${currentCentury}00`],
|
||||
[2043, "2043"], // valid year with a length of four should be taken directly
|
||||
[24, `${currentCentury}24`],
|
||||
[3054, "3054"], // valid year with a length of four should be taken directly
|
||||
[31423524543, `${currentCentury}43`],
|
||||
[4, `${currentCentury}04`],
|
||||
[null, null],
|
||||
[undefined, null],
|
||||
["-12", `${currentCentury}12`],
|
||||
["", null],
|
||||
["0", `${currentCentury}00`],
|
||||
["00", `${currentCentury}00`],
|
||||
["000", `${currentCentury}00`],
|
||||
["0000", `${currentCentury}00`],
|
||||
["00000", `${currentCentury}00`],
|
||||
["0234234", `${currentCentury}34`],
|
||||
["04", `${currentCentury}04`],
|
||||
["2043", "2043"], // valid year with a length of four should be taken directly
|
||||
["24", `${currentCentury}24`],
|
||||
["3054", "3054"], // valid year with a length of four should be taken directly
|
||||
["31423524543", `${currentCentury}43`],
|
||||
["4", `${currentCentury}04`],
|
||||
["aaaa", null],
|
||||
["adgshsfhjsdrtyhsrth", null],
|
||||
["agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", `${currentCentury}45`],
|
||||
];
|
||||
}
|
||||
|
||||
describe("normalizeExpiryYearFormat", () => {
|
||||
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
|
||||
|
||||
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
|
||||
|
||||
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
|
||||
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
|
||||
const formattedValue = normalizeExpiryYearFormat(inputValue);
|
||||
|
||||
expect(formattedValue).toEqual(expectedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the year 3107", () => {
|
||||
const theDistantFuture = new Date(Date.UTC(3107, 1, 1));
|
||||
jest.spyOn(Date, "now").mockReturnValue(theDistantFuture.valueOf());
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.setSystemTime(theDistantFuture);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const currentCentury = `${new Date(Date.now()).getFullYear()}`.slice(0, 2);
|
||||
expect(currentCentury).toBe("31");
|
||||
|
||||
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
|
||||
|
||||
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
|
||||
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
|
||||
const formattedValue = normalizeExpiryYearFormat(inputValue);
|
||||
|
||||
expect(formattedValue).toEqual(expectedValue);
|
||||
});
|
||||
});
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
});
|
||||
|
||||
function getCardExpiryDateValues() {
|
||||
const currentDate = new Date();
|
||||
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
// `Date` months are zero-indexed, our expiry date month inputs are one-indexed
|
||||
const currentMonth = currentDate.getMonth() + 1;
|
||||
|
||||
return [
|
||||
[null, null, false], // no month, no year
|
||||
[undefined, undefined, false], // no month, no year, invalid values
|
||||
["", "", false], // no month, no year, invalid values
|
||||
["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values
|
||||
["0", `${currentYear}`, true], // invalid month
|
||||
["0", `${currentYear - 1}`, true], // invalid 0 month
|
||||
["00", `${currentYear + 1}`, false], // invalid 0 month
|
||||
[`${currentMonth}`, "0000", true], // current month, in the year 2000
|
||||
[null, `${currentYear}`.slice(-2), false], // no month, this year
|
||||
[null, `${currentYear - 1}`.slice(-2), true], // no month, last year
|
||||
["1", null, false], // no year, January
|
||||
["1", `${currentYear - 1}`, true], // January last year
|
||||
["13", `${currentYear}`, false], // 12 + 1 is Feb. in the next year (Date is zero-indexed)
|
||||
[`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired
|
||||
[`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over)
|
||||
[`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over)
|
||||
[`${currentMonth - 1}`, `${currentYear}`, true], // last month
|
||||
[`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now
|
||||
];
|
||||
}
|
||||
|
||||
describe("isCardExpired", () => {
|
||||
const expiryYearValueFormats = getCardExpiryDateValues();
|
||||
|
||||
expiryYearValueFormats.forEach(
|
||||
([inputMonth, inputYear, expectedValue]: [string | null, string | null, boolean]) => {
|
||||
it(`should return ${expectedValue} when the card expiry month is ${inputMonth} and the card expiry year is ${inputYear}`, () => {
|
||||
const testCardView = new CardView();
|
||||
testCardView.expMonth = inputMonth;
|
||||
testCardView.expYear = inputYear;
|
||||
|
||||
const cardIsExpired = isCardExpired(testCardView);
|
||||
|
||||
expect(cardIsExpired).toBe(expectedValue);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const combinedDateTestValues = [
|
||||
" 2024 / 05 ",
|
||||
"05 2024",
|
||||
"05 2024", // Tab whitespace character
|
||||
"05 2024", // Em Quad
|
||||
"05 2024", // Em Space
|
||||
"05 2024", // En Quad
|
||||
"05 2024", // En Space
|
||||
"05 2024", // Figure Space
|
||||
"05 2024", // Four-Per-Em Space
|
||||
"05 2024", // Hair Space
|
||||
"05 2024", // Ideographic Space
|
||||
"05 2024", // Medium Mathematical Space
|
||||
"05 2024", // No-Break Space
|
||||
"05 2024", // ogham space mark
|
||||
"05 2024", // Punctuation Space
|
||||
"05 2024", // Six-Per-Em Space
|
||||
"05 2024", // Thin Space
|
||||
"05 2024", // Three-Per-Em Space
|
||||
"05 24",
|
||||
"05-2024",
|
||||
"05-24",
|
||||
"05.2024",
|
||||
"05.24",
|
||||
"05/2024",
|
||||
"05/24",
|
||||
"052024",
|
||||
"0524",
|
||||
"2024 05",
|
||||
"2024 5",
|
||||
"2024-05",
|
||||
"2024-5",
|
||||
"2024.05",
|
||||
"2024.5",
|
||||
"2024/05",
|
||||
"2024/5",
|
||||
"202405",
|
||||
"20245",
|
||||
"24 05",
|
||||
"24 5",
|
||||
"24-05",
|
||||
"24-5",
|
||||
"24.05",
|
||||
"24.5",
|
||||
"24/05",
|
||||
"24/5",
|
||||
"2405",
|
||||
"5 2024",
|
||||
"5 24",
|
||||
"5-2024",
|
||||
"5-24",
|
||||
"5.2024",
|
||||
"5.24",
|
||||
"5/2024",
|
||||
"5/24",
|
||||
"52024",
|
||||
];
|
||||
const expectedParsedValue = ["2024", "5"];
|
||||
describe("parseYearMonthExpiry", () => {
|
||||
it('returns "null" expiration year and month values when a value of "" is passed', () => {
|
||||
expect(parseYearMonthExpiry("")).toStrictEqual([null, null]);
|
||||
});
|
||||
|
||||
it('returns "null" expiration year and month values when a value of "/" is passed', () => {
|
||||
expect(parseYearMonthExpiry("/")).toStrictEqual([null, null]);
|
||||
});
|
||||
|
||||
combinedDateTestValues.forEach((combinedDate) => {
|
||||
it(`returns an expiration year value of "${expectedParsedValue[0]}" and month value of "${expectedParsedValue[1]}" when a value of "${combinedDate}" is passed`, () => {
|
||||
expect(parseYearMonthExpiry(combinedDate)).toStrictEqual(expectedParsedValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an expiration year value of "2002" and month value of "2" when a value of "022" is passed', () => {
|
||||
expect(parseYearMonthExpiry("022")).toStrictEqual(["2002", "2"]);
|
||||
});
|
||||
|
||||
it('returns an expiration year value of "2002" and month value of "2" when a value of "202" is passed', () => {
|
||||
expect(parseYearMonthExpiry("202")).toStrictEqual(["2002", "2"]);
|
||||
});
|
||||
|
||||
it('returns an expiration year value of "2002" and month value of "1" when a value of "1/2/3/4" is passed', () => {
|
||||
expect(parseYearMonthExpiry("1/2/3/4")).toStrictEqual(["2002", "1"]);
|
||||
});
|
||||
|
||||
it('returns valid expiration year and month values when a value of "198" is passed', () => {
|
||||
// This static value will cause the test to fail in 2098
|
||||
const testValue = "198";
|
||||
const parsedValue = parseYearMonthExpiry(testValue);
|
||||
|
||||
expect(parsedValue[0]).toHaveLength(4);
|
||||
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||
|
||||
expect(parsedValue).toStrictEqual(["2098", "1"]);
|
||||
});
|
||||
|
||||
// Ambiguous input cases: we use try/catch for these cases as a workaround to accept either
|
||||
// outcome (both are valid interpretations) in the event of any future code changes.
|
||||
describe("ambiguous input cases", () => {
|
||||
it('returns valid expiration year and month values when a value of "111" is passed', () => {
|
||||
const testValue = "111";
|
||||
const parsedValue = parseYearMonthExpiry(testValue);
|
||||
|
||||
expect(parsedValue[0]).toHaveLength(4);
|
||||
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||
|
||||
try {
|
||||
expect(parsedValue).toStrictEqual(["2011", "1"]);
|
||||
} catch {
|
||||
expect(parsedValue).toStrictEqual(["2001", "11"]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns valid expiration year and month values when a value of "212" is passed', () => {
|
||||
const testValue = "212";
|
||||
const parsedValue = parseYearMonthExpiry(testValue);
|
||||
|
||||
expect(parsedValue[0]).toHaveLength(4);
|
||||
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||
|
||||
try {
|
||||
expect(parsedValue).toStrictEqual(["2012", "2"]);
|
||||
} catch {
|
||||
expect(parsedValue).toStrictEqual(["2021", "2"]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns valid expiration year and month values when a value of "245" is passed', () => {
|
||||
const testValue = "245";
|
||||
const parsedValue = parseYearMonthExpiry(testValue);
|
||||
|
||||
expect(parsedValue[0]).toHaveLength(4);
|
||||
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||
|
||||
try {
|
||||
expect(parsedValue).toStrictEqual(["2045", "2"]);
|
||||
} catch {
|
||||
expect(parsedValue).toStrictEqual(["2024", "5"]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns valid expiration year and month values when a value of "524" is passed', () => {
|
||||
const testValue = "524";
|
||||
const parsedValue = parseYearMonthExpiry(testValue);
|
||||
|
||||
expect(parsedValue[0]).toHaveLength(4);
|
||||
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||
|
||||
try {
|
||||
expect(parsedValue).toStrictEqual(["2024", "5"]);
|
||||
} catch {
|
||||
expect(parsedValue).toStrictEqual(["2052", "4"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
307
libs/common/src/autofill/utils.ts
Normal file
307
libs/common/src/autofill/utils.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
DelimiterPatternExpression,
|
||||
ExpiryFullYearPattern,
|
||||
ExpiryFullYearPatternExpression,
|
||||
IrrelevantExpiryCharactersPatternExpression,
|
||||
MonthPatternExpression,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
|
||||
type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
|
||||
|
||||
/**
|
||||
* Takes a string or number value and returns a string value formatted as a valid 4-digit year
|
||||
*
|
||||
* @param {(string | number)} yearInput
|
||||
* @return {*} {(Year | null)}
|
||||
*/
|
||||
export function normalizeExpiryYearFormat(yearInput: string | number): Year | null {
|
||||
// The input[type="number"] is returning a number, convert it to a string
|
||||
// An empty field returns null, avoid casting `"null"` to a string
|
||||
const yearInputIsEmpty = yearInput == null || yearInput === "";
|
||||
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
|
||||
|
||||
// Exit early if year is already formatted correctly or empty
|
||||
if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) {
|
||||
return expirationYear as Year;
|
||||
}
|
||||
|
||||
expirationYear = expirationYear
|
||||
// For safety, because even input[type="number"] will allow decimals
|
||||
.replace(/[^\d]/g, "")
|
||||
// remove any leading zero padding (leave the last leading zero if it ends the string)
|
||||
.replace(/^[0]+(?=.)/, "");
|
||||
|
||||
if (expirationYear === "") {
|
||||
expirationYear = null;
|
||||
}
|
||||
|
||||
// given the context of payment card expiry, a year character length of 3, or over 4
|
||||
// is more likely to be a mistake than an intentional value for the far past or far future.
|
||||
if (expirationYear && expirationYear.length !== 4) {
|
||||
const paddedYear = ("00" + expirationYear).slice(-2);
|
||||
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
|
||||
|
||||
expirationYear = currentCentury + paddedYear;
|
||||
}
|
||||
|
||||
return expirationYear as Year | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
|
||||
* the card is expired.
|
||||
*
|
||||
* @param {CardView} cipherCard
|
||||
* @return {*} {boolean}
|
||||
*/
|
||||
export function isCardExpired(cipherCard: CardView): boolean {
|
||||
if (cipherCard) {
|
||||
const { expMonth = null, expYear = null } = cipherCard;
|
||||
|
||||
const now = new Date();
|
||||
const normalizedYear = normalizeExpiryYearFormat(expYear);
|
||||
|
||||
// If the card year is before the current year, don't bother checking the month
|
||||
if (normalizedYear && parseInt(normalizedYear, 10) < now.getFullYear()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedYear && expMonth) {
|
||||
const parsedMonthInteger = parseInt(expMonth, 10);
|
||||
|
||||
const parsedMonth = isNaN(parsedMonthInteger)
|
||||
? 0
|
||||
: // Add a month floor of 0 to protect against an invalid low month value of "0" or negative integers
|
||||
Math.max(
|
||||
// `Date` months are zero-indexed
|
||||
parsedMonthInteger - 1,
|
||||
0,
|
||||
);
|
||||
|
||||
const parsedYear = parseInt(normalizedYear, 10);
|
||||
|
||||
// First day of the next month minus one, to get last day of the card month
|
||||
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 0);
|
||||
|
||||
return cardExpiry < now;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to split a string into date segments on the basis of expected formats and delimiter symbols.
|
||||
*
|
||||
* @param {string} combinedExpiryValue
|
||||
* @return {*} {string[]}
|
||||
*/
|
||||
function splitCombinedDateValues(combinedExpiryValue: string): string[] {
|
||||
let sanitizedValue = combinedExpiryValue
|
||||
.replace(IrrelevantExpiryCharactersPatternExpression, "")
|
||||
.trim();
|
||||
|
||||
// Do this after initial value replace to avoid identifying leading whitespace as delimiter
|
||||
const parsedDelimiter = sanitizedValue.match(DelimiterPatternExpression)?.[0] || null;
|
||||
|
||||
let dateParts = [sanitizedValue];
|
||||
|
||||
if (parsedDelimiter?.length) {
|
||||
// If the parsed delimiter is a whitespace character, assign 's' (character class) instead
|
||||
const delimiterPattern = /\s/.test(parsedDelimiter) ? "\\s" : "\\" + parsedDelimiter;
|
||||
|
||||
sanitizedValue = sanitizedValue
|
||||
// Remove all other delimiter characters not identified as the delimiter
|
||||
.replace(new RegExp(`[^\\d${delimiterPattern}]`, "g"), "")
|
||||
// Also de-dupe the delimiter character
|
||||
.replace(new RegExp(`[${delimiterPattern}]{2,}`, "g"), parsedDelimiter);
|
||||
|
||||
dateParts = sanitizedValue.split(parsedDelimiter);
|
||||
}
|
||||
|
||||
return (
|
||||
dateParts
|
||||
// remove values that have no length
|
||||
.filter((splitValue) => splitValue?.length)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of split card expiry date parts,
|
||||
* returns an array of those values ordered by year then month
|
||||
*
|
||||
* @param {string[]} splitDateInput
|
||||
* @return {*} {([string | null, string | null])}
|
||||
*/
|
||||
function parseDelimitedYearMonthExpiry([firstPart, secondPart]: string[]): [string, string] {
|
||||
// Conditionals here are structured to avoid unnecessary evaluations and are ordered
|
||||
// from more authoritative checks to checks yielding increasingly inferred conclusions
|
||||
|
||||
// If a 4-digit value is found (when there are multiple parts), it can't be month
|
||||
if (ExpiryFullYearPatternExpression.test(firstPart)) {
|
||||
return [firstPart, secondPart];
|
||||
}
|
||||
|
||||
// If a 4-digit value is found (when there are multiple parts), it can't be month
|
||||
if (ExpiryFullYearPatternExpression.test(secondPart)) {
|
||||
return [secondPart, firstPart];
|
||||
}
|
||||
|
||||
// If it's a two digit value that doesn't match against month pattern, assume it's a year
|
||||
if (/\d{2}/.test(firstPart) && !MonthPatternExpression.test(firstPart)) {
|
||||
return [firstPart, secondPart];
|
||||
}
|
||||
|
||||
// If it's a two digit value that doesn't match against month pattern, assume it's a year
|
||||
if (/\d{2}/.test(secondPart) && !MonthPatternExpression.test(secondPart)) {
|
||||
return [secondPart, firstPart];
|
||||
}
|
||||
|
||||
// Values are too ambiguous (e.g. "12/09"). For the most part,
|
||||
// a month-looking value likely is, at the time of writing (year 2024).
|
||||
let parsedYear = firstPart;
|
||||
let parsedMonth = secondPart;
|
||||
|
||||
if (MonthPatternExpression.test(firstPart)) {
|
||||
parsedYear = secondPart;
|
||||
parsedMonth = firstPart;
|
||||
}
|
||||
|
||||
return [parsedYear, parsedMonth];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a single string of integers, attempts to identify card expiry date portions within
|
||||
* and return values ordered by year then month
|
||||
*
|
||||
* @param {string} dateInput
|
||||
* @return {*} {([string | null, string | null])}
|
||||
*/
|
||||
function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, string | null] {
|
||||
if (dateInput.length > 4) {
|
||||
// e.g.
|
||||
// "052024"
|
||||
// "202405"
|
||||
// "20245"
|
||||
// "52024"
|
||||
|
||||
// If the value is over 5-characters long, it likely has a full year format in it
|
||||
const [parsedYear, parsedMonth] = dateInput
|
||||
.split(new RegExp(`(?=${ExpiryFullYearPattern})|(?<=${ExpiryFullYearPattern})`, "g"))
|
||||
.sort((current: string, next: string) => (current.length > next.length ? -1 : 1));
|
||||
|
||||
return [parsedYear, parsedMonth];
|
||||
}
|
||||
|
||||
if (dateInput.length === 4) {
|
||||
// e.g.
|
||||
// "0524"
|
||||
// "2405"
|
||||
|
||||
// If the `sanitizedFirstPart` value is a length of 4, it must be split in half, since
|
||||
// neither a year or month will be represented with three characters
|
||||
const splitFirstPartFirstHalf = dateInput.slice(0, 2);
|
||||
const splitFirstPartSecondHalf = dateInput.slice(-2);
|
||||
|
||||
let parsedYear = splitFirstPartSecondHalf;
|
||||
let parsedMonth = splitFirstPartFirstHalf;
|
||||
|
||||
// If the first part doesn't match a month pattern, assume it's a year
|
||||
if (!MonthPatternExpression.test(splitFirstPartFirstHalf)) {
|
||||
parsedYear = splitFirstPartFirstHalf;
|
||||
parsedMonth = splitFirstPartSecondHalf;
|
||||
}
|
||||
|
||||
return [parsedYear, parsedMonth];
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// "245"
|
||||
// "202"
|
||||
// "212"
|
||||
// "022"
|
||||
// "111"
|
||||
|
||||
// A valid year representation here must be two characters so try to find it first.
|
||||
|
||||
let parsedYear = null;
|
||||
let parsedMonth = null;
|
||||
|
||||
// Split if there is a digit with a leading zero
|
||||
const splitFirstPartOnLeadingZero = dateInput.split(/(?<=0[1-9]{1})|(?=0[1-9]{1})/);
|
||||
|
||||
// Assume a leading zero indicates a month in ambiguous cases (e.g. "202"), since we're
|
||||
// dealing with expiry dates and the next two-digit year with a leading zero will be 2100
|
||||
if (splitFirstPartOnLeadingZero.length > 1) {
|
||||
parsedYear = splitFirstPartOnLeadingZero[0];
|
||||
parsedMonth = splitFirstPartOnLeadingZero[1];
|
||||
|
||||
if (splitFirstPartOnLeadingZero[0].startsWith("0")) {
|
||||
parsedMonth = splitFirstPartOnLeadingZero[0];
|
||||
parsedYear = splitFirstPartOnLeadingZero[1];
|
||||
}
|
||||
} else {
|
||||
// Here, a year has to be two-digits, and a month can't be more than one, so assume the first two digits that are greater than the current year is the year representation.
|
||||
parsedYear = dateInput.slice(0, 2);
|
||||
parsedMonth = dateInput.slice(-1);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10);
|
||||
const normalizedParsedYearAlternative = parseInt(
|
||||
normalizeExpiryYearFormat(dateInput.slice(-2)),
|
||||
10,
|
||||
);
|
||||
|
||||
if (normalizedParsedYear < currentYear && normalizedParsedYearAlternative >= currentYear) {
|
||||
parsedYear = dateInput.slice(-2);
|
||||
parsedMonth = dateInput.slice(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [parsedYear, parsedMonth];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse year and month parts of a combined expiry date value.
|
||||
*
|
||||
* @param {string} combinedExpiryValue
|
||||
* @return {*} {([string | null, string | null])}
|
||||
*/
|
||||
export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null, string | null] {
|
||||
let parsedYear = null;
|
||||
let parsedMonth = null;
|
||||
|
||||
const dateParts = splitCombinedDateValues(combinedExpiryValue);
|
||||
|
||||
if (dateParts.length < 1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const sanitizedFirstPart =
|
||||
dateParts[0]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
|
||||
const sanitizedSecondPart =
|
||||
dateParts[1]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
|
||||
|
||||
// If there is only one date part, no delimiter was found in the passed value
|
||||
if (dateParts.length === 1) {
|
||||
[parsedYear, parsedMonth] = parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
|
||||
}
|
||||
// There are multiple date parts
|
||||
else {
|
||||
[parsedYear, parsedMonth] = parseDelimitedYearMonthExpiry([
|
||||
sanitizedFirstPart,
|
||||
sanitizedSecondPart,
|
||||
]);
|
||||
}
|
||||
|
||||
const normalizedParsedYear = normalizeExpiryYearFormat(parsedYear);
|
||||
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
|
||||
|
||||
// Set "empty" values to null
|
||||
parsedYear = normalizedParsedYear?.length ? normalizedParsedYear : null;
|
||||
parsedMonth = normalizedParsedMonth?.length ? normalizedParsedMonth : null;
|
||||
|
||||
return [parsedYear, parsedMonth];
|
||||
}
|
||||
@@ -24,8 +24,6 @@ export enum FeatureFlag {
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page",
|
||||
IdpAutoSubmitLogin = "idp-auto-submit-login",
|
||||
DeviceTrustLogging = "pm-8285-device-trust-logging",
|
||||
AuthenticatorTwoFactorToken = "authenticator-2fa-token",
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub",
|
||||
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
|
||||
@@ -34,7 +32,6 @@ export enum FeatureFlag {
|
||||
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
||||
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
|
||||
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
|
||||
StorageReseedRefactor = "storage-reseed-refactor",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
}
|
||||
|
||||
@@ -56,7 +53,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EnableConsolidatedBilling]: FALSE,
|
||||
[FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE,
|
||||
[FeatureFlag.EnableDeleteProvider]: FALSE,
|
||||
[FeatureFlag.ExtensionRefresh]: true,
|
||||
[FeatureFlag.ExtensionRefresh]: FALSE,
|
||||
[FeatureFlag.PersistPopupView]: FALSE,
|
||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||
@@ -70,14 +67,11 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE,
|
||||
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
|
||||
[FeatureFlag.DeviceTrustLogging]: FALSE,
|
||||
[FeatureFlag.AuthenticatorTwoFactorToken]: FALSE,
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: true,
|
||||
[FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE,
|
||||
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
|
||||
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
||||
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
|
||||
[FeatureFlag.StorageReseedRefactor]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
||||
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
|
||||
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
UserPublicKey,
|
||||
} from "../../types/key";
|
||||
import { KeySuffixOptions, HashPurpose } from "../enums";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
@@ -373,37 +372,6 @@ export abstract class CryptoService {
|
||||
* @param userId The desired user
|
||||
*/
|
||||
abstract clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string): Promise<void>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.encrypt
|
||||
*/
|
||||
abstract encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey): Promise<EncString>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.encryptToBytes
|
||||
*/
|
||||
abstract encryptToBytes(
|
||||
plainValue: Uint8Array,
|
||||
key?: SymmetricCryptoKey,
|
||||
): Promise<EncArrayBuffer>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToBytes
|
||||
*/
|
||||
abstract decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToUtf8
|
||||
*/
|
||||
abstract decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise<string>;
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToBytes
|
||||
*/
|
||||
abstract decryptFromBytes(
|
||||
encBuffer: EncArrayBuffer,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Retrieves all the keys needed for decrypting Ciphers
|
||||
|
||||
@@ -48,7 +48,6 @@ import { StateService } from "../abstractions/state.service";
|
||||
import { KeySuffixOptions, HashPurpose, EncryptionType } from "../enums";
|
||||
import { convertValues } from "../misc/convert-values";
|
||||
import { EFFLongWordList } from "../misc/wordlist";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
import { EncString, EncryptedString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { ActiveUserState, StateProvider } from "../state";
|
||||
@@ -859,58 +858,6 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
// --DEPRECATED METHODS--
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.encrypt
|
||||
*/
|
||||
async encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey): Promise<EncString> {
|
||||
key ||= await this.getUserKeyWithLegacySupport();
|
||||
return await this.encryptService.encrypt(plainValue, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.encryptToBytes
|
||||
*/
|
||||
async encryptToBytes(plainValue: Uint8Array, key?: SymmetricCryptoKey): Promise<EncArrayBuffer> {
|
||||
key ||= await this.getUserKeyWithLegacySupport();
|
||||
return this.encryptService.encryptToBytes(plainValue, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToBytes
|
||||
*/
|
||||
async decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise<Uint8Array> {
|
||||
key ||= await this.getUserKeyWithLegacySupport();
|
||||
return this.encryptService.decryptToBytes(encString, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToUtf8
|
||||
*/
|
||||
async decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise<string> {
|
||||
key ||= await this.getUserKeyWithLegacySupport();
|
||||
return await this.encryptService.decryptToUtf8(encString, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToBytes
|
||||
*/
|
||||
async decryptFromBytes(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> {
|
||||
if (encBuffer == null) {
|
||||
throw new Error("No buffer provided for decryption.");
|
||||
}
|
||||
|
||||
key ||= await this.getUserKeyWithLegacySupport();
|
||||
|
||||
return this.encryptService.decryptToBytes(encBuffer, key);
|
||||
}
|
||||
|
||||
userKey$(userId: UserId): Observable<UserKey> {
|
||||
return this.stateProvider.getUser(userId, USER_KEY).state$;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TextEncoder } from "util";
|
||||
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -53,7 +53,9 @@ describe("FidoAuthenticatorService", () => {
|
||||
userInterface = mock<Fido2UserInterfaceService>();
|
||||
userInterfaceSession = mock<Fido2UserInterfaceSession>();
|
||||
userInterface.newSession.mockResolvedValue(userInterfaceSession);
|
||||
syncService = mock<SyncService>();
|
||||
syncService = mock<SyncService>({
|
||||
activeUserLastSync$: () => of(new Date()),
|
||||
});
|
||||
accountService = mock<AccountService>();
|
||||
authenticator = new Fido2AuthenticatorService(
|
||||
cipherService,
|
||||
|
||||
@@ -94,7 +94,14 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
}
|
||||
|
||||
await userInterfaceSession.ensureUnlockedVault();
|
||||
await this.syncService.fullSync(false);
|
||||
|
||||
// Avoid syncing if we did it reasonably soon as the only reason for syncing is to validate excludeCredentials
|
||||
const lastSync = await firstValueFrom(this.syncService.activeUserLastSync$());
|
||||
const threshold = new Date().getTime() - 1000 * 60 * 30; // 30 minutes ago
|
||||
|
||||
if (!lastSync || lastSync.getTime() < threshold) {
|
||||
await this.syncService.fullSync(false);
|
||||
}
|
||||
|
||||
const existingCipherIds = await this.findExcludedCredentials(
|
||||
params.excludeCredentialDescriptorList,
|
||||
@@ -223,15 +230,17 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
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);
|
||||
// Try to find the passkey locally before causing a sync to speed things up
|
||||
// only skip syncing if we found credentials AND all of them have a counter = 0
|
||||
cipherOptions = await this.findCredential(params, cipherOptions);
|
||||
if (
|
||||
cipherOptions.length === 0 ||
|
||||
cipherOptions.some((c) => c.login.fido2Credentials.some((p) => p.counter > 0))
|
||||
) {
|
||||
// If no passkey is found, or any had a non-zero counter, sync to get the latest data
|
||||
await this.syncService.fullSync(false);
|
||||
cipherOptions = await this.findCredential(params, cipherOptions);
|
||||
}
|
||||
|
||||
if (cipherOptions.length === 0) {
|
||||
@@ -335,6 +344,21 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
}
|
||||
}
|
||||
|
||||
private async findCredential(
|
||||
params: Fido2AuthenticatorGetAssertionParams,
|
||||
cipherOptions: CipherView[],
|
||||
) {
|
||||
if (params.allowCredentialDescriptorList?.length > 0) {
|
||||
cipherOptions = await this.findCredentialsById(
|
||||
params.allowCredentialDescriptorList,
|
||||
params.rpId,
|
||||
);
|
||||
} else {
|
||||
cipherOptions = await this.findCredentialsByRp(params.rpId);
|
||||
}
|
||||
return cipherOptions;
|
||||
}
|
||||
|
||||
private requiresUserVerificationPrompt(
|
||||
params: Fido2AuthenticatorGetAssertionParams,
|
||||
cipherOptions: CipherView[],
|
||||
|
||||
@@ -17,8 +17,10 @@ export class Fido2Utils {
|
||||
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str));
|
||||
}
|
||||
|
||||
static bufferSourceToUint8Array(bufferSource: BufferSource) {
|
||||
if (Fido2Utils.isArrayBuffer(bufferSource)) {
|
||||
static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array {
|
||||
if (bufferSource instanceof Uint8Array) {
|
||||
return bufferSource;
|
||||
} else if (Fido2Utils.isArrayBuffer(bufferSource)) {
|
||||
return new Uint8Array(bufferSource);
|
||||
} else {
|
||||
return new Uint8Array(bufferSource.buffer);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GlobalState } from "./global-state";
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
|
||||
/**
|
||||
* A provider for geting an implementation of global state scoped to the given key.
|
||||
* A provider for getting an implementation of global state scoped to the given key.
|
||||
*/
|
||||
export abstract class GlobalStateProvider {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Observable, map } from "rxjs";
|
||||
import { Observable, combineLatest, map } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../abstractions/config/config.service";
|
||||
import { ThemeType } from "../enums";
|
||||
import { GlobalStateProvider, KeyDefinition, THEMING_DISK } from "../state";
|
||||
|
||||
@@ -16,17 +18,32 @@ export abstract class ThemeStateService {
|
||||
abstract setSelectedTheme(theme: ThemeType): Promise<void>;
|
||||
}
|
||||
|
||||
const THEME_SELECTION = new KeyDefinition<ThemeType>(THEMING_DISK, "selection", {
|
||||
export const THEME_SELECTION = new KeyDefinition<ThemeType>(THEMING_DISK, "selection", {
|
||||
deserializer: (s) => s,
|
||||
});
|
||||
|
||||
export class DefaultThemeStateService implements ThemeStateService {
|
||||
private readonly selectedThemeState = this.globalStateProvider.get(THEME_SELECTION);
|
||||
|
||||
selectedTheme$ = this.selectedThemeState.state$.pipe(map((theme) => theme ?? this.defaultTheme));
|
||||
selectedTheme$ = combineLatest([
|
||||
this.selectedThemeState.state$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.ExtensionRefresh),
|
||||
]).pipe(
|
||||
map(([theme, isExtensionRefresh]) => {
|
||||
// The extension refresh should not allow for Nord or SolarizedDark
|
||||
// Default the user to their system theme
|
||||
if (isExtensionRefresh && [ThemeType.Nord, ThemeType.SolarizedDark].includes(theme)) {
|
||||
return ThemeType.System;
|
||||
}
|
||||
|
||||
return theme;
|
||||
}),
|
||||
map((theme) => theme ?? this.defaultTheme),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
private configService: ConfigService,
|
||||
private defaultTheme: ThemeType = ThemeType.System,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { makeStaticByteArray, mockEnc } from "../../../../../spec";
|
||||
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||
@@ -89,6 +92,7 @@ describe("Send", () => {
|
||||
it("Decrypt", async () => {
|
||||
const text = mock<SendText>();
|
||||
text.decrypt.mockResolvedValue("textView" as any);
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
@@ -106,13 +110,13 @@ describe("Send", () => {
|
||||
send.disabled = false;
|
||||
send.hideEmail = true;
|
||||
|
||||
const encryptService = mock<EncryptService>();
|
||||
const cryptoService = mock<CryptoService>();
|
||||
cryptoService.decryptToBytes
|
||||
.calledWith(send.key, null)
|
||||
encryptService.decryptToBytes
|
||||
.calledWith(send.key, userKey)
|
||||
.mockResolvedValue(makeStaticByteArray(32));
|
||||
cryptoService.makeSendKey.mockResolvedValue("cryptoKey" as any);
|
||||
|
||||
const encryptService = mock<EncryptService>();
|
||||
cryptoService.getUserKey.mockResolvedValue(userKey);
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
|
||||
|
||||
|
||||
@@ -73,9 +73,11 @@ export class Send extends Domain {
|
||||
const model = new SendView(this);
|
||||
|
||||
const cryptoService = Utils.getContainerService().getCryptoService();
|
||||
const encryptService = Utils.getContainerService().getEncryptService();
|
||||
|
||||
try {
|
||||
model.key = await cryptoService.decryptToBytes(this.key, null);
|
||||
const sendKeyEncryptionKey = await cryptoService.getUserKey();
|
||||
model.key = await encryptService.decryptToBytes(this.key, sendKeyEncryptionKey);
|
||||
model.cryptoKey = await cryptoService.makeSendKey(model.key);
|
||||
} catch (e) {
|
||||
// TODO: error?
|
||||
|
||||
@@ -57,6 +57,8 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
send.disabled = model.disabled;
|
||||
send.hideEmail = model.hideEmail;
|
||||
send.maxAccessCount = model.maxAccessCount;
|
||||
send.deletionDate = model.deletionDate;
|
||||
send.expirationDate = model.expirationDate;
|
||||
if (model.key == null) {
|
||||
const key = await this.keyGenerationService.createKeyWithPurpose(
|
||||
128,
|
||||
|
||||
24
libs/common/src/tools/state/identity-state-constraint.ts
Normal file
24
libs/common/src/tools/state/identity-state-constraint.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Constraints, StateConstraints } from "../types";
|
||||
|
||||
// The constraints type shares the properties of the state,
|
||||
// but never has any members
|
||||
const EMPTY_CONSTRAINTS = new Proxy<any>(Object.freeze({}), {
|
||||
get() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
/** A constraint that does nothing. */
|
||||
export class IdentityConstraint<State extends object> implements StateConstraints<State> {
|
||||
/** Instantiate the identity constraint */
|
||||
constructor() {}
|
||||
|
||||
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
|
||||
|
||||
adjust(state: State) {
|
||||
return state;
|
||||
}
|
||||
fix(state: State) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { StateConstraints } from "../types";
|
||||
|
||||
import { isDynamic } from "./state-constraints-dependency";
|
||||
|
||||
type TestType = { foo: string };
|
||||
|
||||
describe("isDynamic", () => {
|
||||
it("returns `true` when the constraint fits the `DynamicStateConstraints` type.", () => {
|
||||
const constraint: any = {
|
||||
calibrate(state: TestType): StateConstraints<TestType> {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const result = isDynamic(constraint);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns `false` when the constraint fails to fit the `DynamicStateConstraints` type.", () => {
|
||||
const constraint: any = {};
|
||||
|
||||
const result = isDynamic(constraint);
|
||||
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
});
|
||||
29
libs/common/src/tools/state/state-constraints-dependency.ts
Normal file
29
libs/common/src/tools/state/state-constraints-dependency.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DynamicStateConstraints, StateConstraints } from "../types";
|
||||
|
||||
/** A pattern for types that depend upon a dynamic set of constraints.
|
||||
*
|
||||
* Consumers of this dependency should track the last-received state and
|
||||
* apply it when application state is received or emitted. If `constraints$`
|
||||
* emits an unrecoverable error, the consumer should continue using the
|
||||
* last-emitted constraints. If `constraints$` completes, the consumer should
|
||||
* continue using the last-emitted constraints.
|
||||
*/
|
||||
export type StateConstraintsDependency<State> = {
|
||||
/** A stream that emits constraints when subscribed and when the
|
||||
* constraints change. The stream should not emit `null` or
|
||||
* `undefined`.
|
||||
*/
|
||||
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>;
|
||||
};
|
||||
|
||||
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.
|
||||
* Otherwise, returns false.
|
||||
* @param constraints the constraint to evaluate.
|
||||
* */
|
||||
export function isDynamic<State>(
|
||||
constraints: StateConstraints<State> | DynamicStateConstraints<State>,
|
||||
): constraints is DynamicStateConstraints<State> {
|
||||
return constraints && "calibrate" in constraints;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
|
||||
|
||||
import { StateConstraintsDependency } from "./state-constraints-dependency";
|
||||
|
||||
/** dependencies accepted by the user state subject */
|
||||
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||
SingleUserDependency &
|
||||
Partial<WhenDependency> &
|
||||
Partial<Dependencies<Dependency>> &
|
||||
Partial<StateConstraintsDependency<State>> & {
|
||||
/** Compute the next stored value. If this is not set, values
|
||||
* provided to `next` unconditionally override state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns the value to store in state
|
||||
*/
|
||||
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
|
||||
/**
|
||||
* Compute whether the state should update. If this is not set, values
|
||||
* provided to `next` always update the state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns `true` if the value should be stored, otherwise `false`.
|
||||
*/
|
||||
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
|
||||
}
|
||||
>;
|
||||
@@ -2,13 +2,37 @@ import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { awaitAsync, FakeSingleUserState } from "../../../spec";
|
||||
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
|
||||
import { StateConstraints } from "../types";
|
||||
|
||||
import { UserStateSubject } from "./user-state-subject";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
type TestType = { foo: string };
|
||||
|
||||
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
|
||||
return Object.freeze({
|
||||
constraints: { foo: { maxLength } },
|
||||
adjust: function (state: TestType): TestType {
|
||||
return {
|
||||
foo: state.foo.slice(0, this.constraints.foo.maxLength),
|
||||
};
|
||||
},
|
||||
fix: function (state: TestType): TestType {
|
||||
return {
|
||||
foo: `finalized|${state.foo.slice(0, this.constraints.foo.maxLength)}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const DynamicFooMaxLength = Object.freeze({
|
||||
expected: fooMaxLength(0),
|
||||
calibrate(state: TestType) {
|
||||
return this.expected;
|
||||
},
|
||||
});
|
||||
|
||||
describe("UserStateSubject", () => {
|
||||
describe("dependencies", () => {
|
||||
it("ignores repeated when$ emissions", async () => {
|
||||
@@ -54,6 +78,19 @@ describe("UserStateSubject", () => {
|
||||
|
||||
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits for constraints$", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(3));
|
||||
const [initResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(initResult).toEqual({ foo: "ini" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("next", () => {
|
||||
@@ -246,6 +283,116 @@ describe("UserStateSubject", () => {
|
||||
|
||||
expect(nextValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies constraints$ on init", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
const [result] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(result).toEqual({ foo: "in" });
|
||||
});
|
||||
|
||||
it("applies dynamic constraints", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
subject.next(expected);
|
||||
const actual = await emission;
|
||||
|
||||
expect(actual).toEqual({ foo: "" });
|
||||
});
|
||||
|
||||
it("applies constraints$ on constraints$ emission", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(1));
|
||||
const [, result] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(result).toEqual({ foo: "i" });
|
||||
});
|
||||
|
||||
it("applies constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
const [, result] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(result).toEqual({ foo: "ne" });
|
||||
});
|
||||
|
||||
it("applies latest constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(3));
|
||||
subject.next({ foo: "next" });
|
||||
const [, , result] = await tracker.pauseUntilReceived(3);
|
||||
|
||||
expect(result).toEqual({ foo: "nex" });
|
||||
});
|
||||
|
||||
it("waits for constraints$", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
constraints$.next(fooMaxLength(3));
|
||||
// `init` is also waiting and is processed before `next`
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(nextResult).toEqual({ foo: "nex" });
|
||||
});
|
||||
|
||||
it("uses the last-emitted value from constraints$ when constraints$ errors", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.error({ some: "error" });
|
||||
subject.next({ foo: "next" });
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(nextResult).toEqual({ foo: "nex" });
|
||||
});
|
||||
|
||||
it("uses the last-emitted value from constraints$ when constraints$ completes", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.complete();
|
||||
subject.next({ foo: "next" });
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(nextResult).toEqual({ foo: "nex" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("error", () => {
|
||||
@@ -474,4 +621,150 @@ describe("UserStateSubject", () => {
|
||||
expect(subject.userId).toEqual(SomeUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withConstraints$", () => {
|
||||
it("emits the next value with an empty constraint", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
subject.next(expected);
|
||||
const actual = await emission;
|
||||
|
||||
expect(actual.state).toEqual(expected);
|
||||
expect(actual.constraints).toEqual({});
|
||||
});
|
||||
|
||||
it("ceases emissions once the subject completes", async () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
subject.complete();
|
||||
subject.next({ foo: "ignored" });
|
||||
const [result] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(result.state).toEqual(initialState);
|
||||
expect(tracker.emissions.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("emits constraints$ on constraints$ emission", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(1);
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
constraints$.next(expected);
|
||||
const result = await emission;
|
||||
|
||||
expect(result.state).toEqual({ foo: "i" });
|
||||
expect(result.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("emits dynamic constraints", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
subject.next(expected);
|
||||
const actual = await emission;
|
||||
|
||||
expect(actual.state).toEqual({ foo: "" });
|
||||
expect(actual.constraints).toEqual(DynamicFooMaxLength.expected.constraints);
|
||||
});
|
||||
|
||||
it("emits constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(2);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
const result = await emission;
|
||||
|
||||
expect(result.state).toEqual({ foo: "ne" });
|
||||
expect(result.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("emits the latest constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(3);
|
||||
constraints$.next(expected);
|
||||
|
||||
const emission = tracker.expectEmission();
|
||||
subject.next({ foo: "next" });
|
||||
const result = await emission;
|
||||
|
||||
expect(result.state).toEqual({ foo: "nex" });
|
||||
expect(result.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("waits for constraints$", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(3);
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
constraints$.next(expected);
|
||||
// `init` is also waiting and is processed before `next`
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(nextResult.state).toEqual({ foo: "nex" });
|
||||
expect(nextResult.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("emits the last-emitted value from constraints$ when constraints$ errors", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(3);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
constraints$.error({ some: "error" });
|
||||
subject.next({ foo: "next" });
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(nextResult.state).toEqual({ foo: "nex" });
|
||||
expect(nextResult.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("emits the last-emitted value from constraints$ when constraints$ completes", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(3);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
constraints$.complete();
|
||||
subject.next({ foo: "next" });
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(nextResult.state).toEqual({ foo: "nex" });
|
||||
expect(nextResult.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,37 +17,20 @@ import {
|
||||
startWith,
|
||||
Observable,
|
||||
Subscription,
|
||||
last,
|
||||
concat,
|
||||
combineLatestWith,
|
||||
catchError,
|
||||
EMPTY,
|
||||
} from "rxjs";
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
import { SingleUserState } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
|
||||
import { WithConstraints } from "../types";
|
||||
|
||||
/** dependencies accepted by the user state subject */
|
||||
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||
SingleUserDependency &
|
||||
Partial<WhenDependency> &
|
||||
Partial<Dependencies<Dependency>> & {
|
||||
/** Compute the next stored value. If this is not set, values
|
||||
* provided to `next` unconditionally override state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns the value to store in state
|
||||
*/
|
||||
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
|
||||
/**
|
||||
* Compute whether the state should update. If this is not set, values
|
||||
* provided to `next` always update the state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns `true` if the value should be stored, otherwise `false`.
|
||||
*/
|
||||
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
|
||||
}
|
||||
>;
|
||||
import { IdentityConstraint } from "./identity-state-constraint";
|
||||
import { isDynamic } from "./state-constraints-dependency";
|
||||
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
|
||||
|
||||
/**
|
||||
* Adapt a state provider to an rxjs subject.
|
||||
@@ -61,7 +44,7 @@ export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||
* @template State the state stored by the subject
|
||||
* @template Dependencies use-specific dependencies provided by the user.
|
||||
*/
|
||||
export class UserStateSubject<State, Dependencies = null>
|
||||
export class UserStateSubject<State extends object, Dependencies = null>
|
||||
extends Observable<State>
|
||||
implements SubjectLike<State>
|
||||
{
|
||||
@@ -99,6 +82,35 @@ export class UserStateSubject<State, Dependencies = null>
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const constraints$ = (
|
||||
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
|
||||
).pipe(
|
||||
// FIXME: this should probably log that an error occurred
|
||||
catchError(() => EMPTY),
|
||||
);
|
||||
|
||||
// normalize input in case this `UserStateSubject` is not the only
|
||||
// observer of the backing store
|
||||
const input$ = combineLatest([this.input, constraints$]).pipe(
|
||||
map(([input, constraints]) => {
|
||||
const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints;
|
||||
const state = calibration.adjust(input);
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
|
||||
// when the output subscription completes, its last-emitted value
|
||||
// loops around to the input for finalization
|
||||
const finalize$ = this.pipe(
|
||||
last(),
|
||||
combineLatestWith(constraints$),
|
||||
map(([output, constraints]) => {
|
||||
const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints;
|
||||
const state = calibration.fix(output);
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
const updates$ = concat(input$, finalize$);
|
||||
|
||||
// observe completion
|
||||
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
|
||||
@@ -106,9 +118,24 @@ export class UserStateSubject<State, Dependencies = null>
|
||||
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
|
||||
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
|
||||
|
||||
// wire subscriptions
|
||||
this.outputSubscription = this.state.state$.subscribe(this.output);
|
||||
this.inputSubscription = combineLatest([this.input, when$, userIdAvailable$])
|
||||
// wire output before input so that output normalizes the current state
|
||||
// before any `next` value is processed
|
||||
this.outputSubscription = this.state.state$
|
||||
.pipe(
|
||||
combineLatestWith(constraints$),
|
||||
map(([rawState, constraints]) => {
|
||||
const calibration = isDynamic(constraints)
|
||||
? constraints.calibrate(rawState)
|
||||
: constraints;
|
||||
const state = calibration.adjust(rawState);
|
||||
return {
|
||||
constraints: calibration.constraints,
|
||||
state,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.subscribe(this.output);
|
||||
this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$])
|
||||
.pipe(
|
||||
filter(([_, when]) => when),
|
||||
map(([state]) => state),
|
||||
@@ -144,14 +171,19 @@ export class UserStateSubject<State, Dependencies = null>
|
||||
* @returns the subscription
|
||||
*/
|
||||
subscribe(observer?: Partial<Observer<State>> | ((value: State) => void) | null): Subscription {
|
||||
return this.output.subscribe(observer);
|
||||
return this.output.pipe(map((wc) => wc.state)).subscribe(observer);
|
||||
}
|
||||
|
||||
// using subjects to ensure the right semantics are followed;
|
||||
// if greater efficiency becomes desirable, consider implementing
|
||||
// `SubjectLike` directly
|
||||
private input = new Subject<State>();
|
||||
private readonly output = new ReplaySubject<State>(1);
|
||||
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
|
||||
|
||||
/** A stream containing settings and their last-applied constraints. */
|
||||
get withConstraints$() {
|
||||
return this.output.asObservable();
|
||||
}
|
||||
|
||||
private inputSubscription: Unsubscribable;
|
||||
private outputSubscription: Unsubscribable;
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Simplify } from "type-fest";
|
||||
|
||||
/** Constraints that are shared by all primitive field types */
|
||||
type PrimitiveConstraint = {
|
||||
/** presence indicates the field is required */
|
||||
required?: true;
|
||||
/** `true` indicates the field is required; otherwise the field is optional */
|
||||
required?: boolean;
|
||||
|
||||
/** `true` indicates the field is immutable; otherwise the field is mutable */
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
/** Constraints that are shared by string fields */
|
||||
@@ -23,29 +26,108 @@ type NumberConstraints = {
|
||||
/** maximum number value. When absent, min value is unbounded. */
|
||||
max?: number;
|
||||
|
||||
/** presence indicates the field only accepts integer values */
|
||||
integer?: true;
|
||||
|
||||
/** requires the number be a multiple of the step value */
|
||||
/** requires the number be a multiple of the step value;
|
||||
* this field must be a positive number. +0 and Infinity are
|
||||
* prohibited. When absent, any number is accepted.
|
||||
* @remarks set this to `1` to require integer values.
|
||||
*/
|
||||
step?: number;
|
||||
};
|
||||
|
||||
/** Constraints that are shared by boolean fields */
|
||||
type BooleanConstraint = {
|
||||
/** When present, the boolean field must have the set value.
|
||||
* When absent or undefined, the boolean field's value is unconstrained.
|
||||
*/
|
||||
requiredValue?: boolean;
|
||||
};
|
||||
|
||||
/** Utility type that transforms a type T into its supported validators.
|
||||
*/
|
||||
export type Constraint<T> = PrimitiveConstraint &
|
||||
(T extends string
|
||||
? StringConstraints
|
||||
: T extends number
|
||||
? NumberConstraints
|
||||
: T extends boolean
|
||||
? BooleanConstraint
|
||||
: never);
|
||||
|
||||
/** Utility type that transforms keys of T into their supported
|
||||
* validators.
|
||||
*/
|
||||
export type Constraints<T> = {
|
||||
[Key in keyof T]: Simplify<
|
||||
PrimitiveConstraint &
|
||||
(T[Key] extends string
|
||||
? StringConstraints
|
||||
: T[Key] extends number
|
||||
? NumberConstraints
|
||||
: never)
|
||||
>;
|
||||
[Key in keyof T]?: Simplify<Constraint<T[Key]>>;
|
||||
};
|
||||
|
||||
/** Utility type that tracks whether a set of constraints was
|
||||
* produced by an active policy.
|
||||
*/
|
||||
export type PolicyConstraints<T> = {
|
||||
/** When true, the constraints were derived from an active policy. */
|
||||
policyInEffect?: boolean;
|
||||
} & Constraints<T>;
|
||||
|
||||
/** utility type for methods that evaluate constraints generically. */
|
||||
export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints;
|
||||
export type AnyConstraint = PrimitiveConstraint &
|
||||
StringConstraints &
|
||||
NumberConstraints &
|
||||
BooleanConstraint;
|
||||
|
||||
/** Extends state message with constraints that apply to the message. */
|
||||
export type WithConstraints<State> = {
|
||||
/** the state */
|
||||
readonly state: State;
|
||||
|
||||
/** the constraints enforced upon the type. */
|
||||
readonly constraints: Constraints<State>;
|
||||
};
|
||||
|
||||
/** Creates constraints that are applied automatically to application
|
||||
* state.
|
||||
* This type is mutually exclusive with `StateConstraints`.
|
||||
*/
|
||||
export type DynamicStateConstraints<State> = {
|
||||
/** Creates constraints with data derived from the input state
|
||||
* @param state the state from which the constraints are initialized.
|
||||
* @remarks this is useful for calculating constraints that
|
||||
* depend upon values from the input state. You should not send these
|
||||
* constraints to the UI, because that would prevent the UI from
|
||||
* offering less restrictive constraints.
|
||||
*/
|
||||
calibrate: (state: State) => StateConstraints<State>;
|
||||
};
|
||||
|
||||
/** Constraints that are applied automatically to application state.
|
||||
* This type is mutually exclusive with `DynamicStateConstraints`.
|
||||
* @remarks this type automatically corrects incoming our outgoing
|
||||
* data. If you would like to prevent invalid data from being
|
||||
* applied, use an rxjs filter and evaluate `Constraints<State>`
|
||||
* instead.
|
||||
*/
|
||||
export type StateConstraints<State> = {
|
||||
/** Well-known constraints of `State` */
|
||||
readonly constraints: Readonly<Constraints<State>>;
|
||||
|
||||
/** Enforces constraints that always hold for the emitted state.
|
||||
* @remarks This is useful for enforcing "override" constraints,
|
||||
* such as when a policy requires a value fall within a specific
|
||||
* range.
|
||||
* @param state the state pending emission from the subject.
|
||||
* @return the value emitted by the subject
|
||||
*/
|
||||
adjust: (state: State) => State;
|
||||
|
||||
/** Enforces constraints that holds when the subject completes.
|
||||
* @remarks This is useful for enforcing "default" constraints,
|
||||
* such as when a policy requires some state is true when data is
|
||||
* first subscribed, but the state may vary thereafter.
|
||||
* @param state the state of the subject immediately before
|
||||
* completion.
|
||||
* @return the value stored to state upon completion.
|
||||
*/
|
||||
fix: (state: State) => State;
|
||||
};
|
||||
|
||||
/** Options that provide contextual information about the application state
|
||||
* when a generator is invoked.
|
||||
|
||||
@@ -15,7 +15,7 @@ export abstract class FolderService implements UserKeyRotationDataProvider<Folde
|
||||
folderViews$: Observable<FolderView[]>;
|
||||
|
||||
clearCache: () => Promise<void>;
|
||||
encrypt: (model: FolderView, key?: SymmetricCryptoKey) => Promise<Folder>;
|
||||
encrypt: (model: FolderView, key: SymmetricCryptoKey) => Promise<Folder>;
|
||||
get: (id: string) => Promise<Folder>;
|
||||
getDecrypted$: (id: string) => Observable<FolderView | undefined>;
|
||||
getAllFromState: () => Promise<Folder[]>;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* This interface defines the a contract for a service that prompts the user to upgrade to premium.
|
||||
* It ensures that PremiumUpgradePromptService contains a promptForPremium method.
|
||||
*/
|
||||
export abstract class PremiumUpgradePromptService {
|
||||
abstract promptForPremium(organizationId?: string): Promise<void>;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { normalizeExpiryYearFormat } from "../../../autofill/utils";
|
||||
import { CardLinkedId as LinkedId } from "../../enums";
|
||||
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||
import { normalizeExpiryYearFormat } from "../../utils";
|
||||
|
||||
import { ItemView } from "./item.view";
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ describe("Cipher Service", () => {
|
||||
cipherFileUploadService,
|
||||
configService,
|
||||
stateProvider,
|
||||
accountService,
|
||||
);
|
||||
|
||||
cipherObj = new Cipher(cipherData);
|
||||
@@ -273,7 +274,7 @@ describe("Cipher Service", () => {
|
||||
cryptoService.makeCipherKey.mockReturnValue(
|
||||
Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey),
|
||||
);
|
||||
cryptoService.encrypt.mockImplementation(encryptText);
|
||||
encryptService.encrypt.mockImplementation(encryptText);
|
||||
|
||||
jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
|
||||
});
|
||||
@@ -285,6 +286,10 @@ describe("Cipher Service", () => {
|
||||
{ uri: "uri", match: UriMatchStrategy.RegularExpression } as LoginUriView,
|
||||
];
|
||||
|
||||
cryptoService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
|
||||
const domain = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(domain.login.uris).toEqual([
|
||||
@@ -301,6 +306,9 @@ describe("Cipher Service", () => {
|
||||
it("is null when feature flag is false", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
cryptoService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
const cipher = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipher.key).toBeNull();
|
||||
@@ -322,6 +330,9 @@ describe("Cipher Service", () => {
|
||||
|
||||
it("is not called when feature flag is false", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
cryptoService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
|
||||
await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
@@ -330,6 +341,9 @@ describe("Cipher Service", () => {
|
||||
|
||||
it("is called when feature flag is true", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
cryptoService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
|
||||
await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { firstValueFrom, map, Observable, skipWhile, switchMap } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { BulkEncryptService } from "@bitwarden/common/platform/abstractions/bulk-encrypt.service";
|
||||
|
||||
@@ -108,6 +109,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
private cipherFileUploadService: CipherFileUploadService,
|
||||
private configService: ConfigService,
|
||||
private stateProvider: StateProvider,
|
||||
private accountService: AccountService,
|
||||
) {
|
||||
this.localDataState = this.stateProvider.getActive(LOCAL_DATA_KEY);
|
||||
this.encryptedCiphersState = this.stateProvider.getActive(ENCRYPTED_CIPHERS);
|
||||
@@ -165,7 +167,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
async encrypt(
|
||||
model: CipherView,
|
||||
userId: UserId,
|
||||
keyForEncryption?: SymmetricCryptoKey,
|
||||
keyForCipherEncryption?: SymmetricCryptoKey,
|
||||
keyForCipherKeyDecryption?: SymmetricCryptoKey,
|
||||
originalCipher: Cipher = null,
|
||||
): Promise<Cipher> {
|
||||
@@ -195,26 +197,21 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
const userOrOrgKey = await this.getKeyForCipherKeyDecryption(cipher, userId);
|
||||
// The keyForEncryption is only used for encrypting the cipher key, not the cipher itself, since cipher key encryption is enabled.
|
||||
// If the caller has provided a key for cipher key encryption, use it. Otherwise, use the user or org key.
|
||||
keyForEncryption ||= userOrOrgKey;
|
||||
keyForCipherEncryption ||= userOrOrgKey;
|
||||
// If the caller has provided a key for cipher key decryption, use it. Otherwise, use the user or org key.
|
||||
keyForCipherKeyDecryption ||= userOrOrgKey;
|
||||
return this.encryptCipherWithCipherKey(
|
||||
model,
|
||||
cipher,
|
||||
keyForEncryption,
|
||||
keyForCipherEncryption,
|
||||
keyForCipherKeyDecryption,
|
||||
);
|
||||
} else {
|
||||
if (keyForEncryption == null && cipher.organizationId != null) {
|
||||
keyForEncryption = await this.cryptoService.getOrgKey(cipher.organizationId);
|
||||
if (keyForEncryption == null) {
|
||||
throw new Error("Cannot encrypt cipher for organization. No key.");
|
||||
}
|
||||
}
|
||||
keyForCipherEncryption ||= await this.getKeyForCipherKeyDecryption(cipher, userId);
|
||||
// We want to ensure that the cipher key is null if cipher key encryption is disabled
|
||||
// so that decryption uses the proper key.
|
||||
cipher.key = null;
|
||||
return this.encryptCipher(model, cipher, keyForEncryption);
|
||||
return this.encryptCipher(model, cipher, keyForCipherEncryption);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +240,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
key,
|
||||
).then(async () => {
|
||||
if (model.key != null) {
|
||||
attachment.key = await this.cryptoService.encrypt(model.key.key, key);
|
||||
attachment.key = await this.encryptService.encrypt(model.key.key, key);
|
||||
}
|
||||
encAttachments.push(attachment);
|
||||
});
|
||||
@@ -1348,7 +1345,9 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
const encBuf = await EncArrayBuffer.fromResponse(attachmentResponse);
|
||||
const decBuf = await this.cryptoService.decryptFromBytes(encBuf, null);
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$);
|
||||
const userKey = await this.cryptoService.getUserKeyWithLegacySupport(activeUserId.id);
|
||||
const decBuf = await this.encryptService.decryptToBytes(encBuf, userKey);
|
||||
|
||||
let encKey: UserKey | OrgKey;
|
||||
encKey = await this.cryptoService.getOrgKey(organizationId);
|
||||
@@ -1412,7 +1411,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
.then(() => {
|
||||
const modelProp = (model as any)[map[theProp] || theProp];
|
||||
if (modelProp && modelProp !== "") {
|
||||
return self.cryptoService.encrypt(modelProp, key);
|
||||
return self.encryptService.encrypt(modelProp, key);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
@@ -1458,7 +1457,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
key,
|
||||
);
|
||||
const uriHash = await this.encryptService.hash(model.login.uris[i].uri, "sha256");
|
||||
loginUri.uriChecksum = await this.cryptoService.encrypt(uriHash, key);
|
||||
loginUri.uriChecksum = await this.encryptService.encrypt(uriHash, key);
|
||||
cipher.login.uris.push(loginUri);
|
||||
}
|
||||
}
|
||||
@@ -1485,8 +1484,8 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
},
|
||||
key,
|
||||
);
|
||||
domainKey.counter = await this.cryptoService.encrypt(String(viewKey.counter), key);
|
||||
domainKey.discoverable = await this.cryptoService.encrypt(
|
||||
domainKey.counter = await this.encryptService.encrypt(String(viewKey.counter), key);
|
||||
domainKey.discoverable = await this.encryptService.encrypt(
|
||||
String(viewKey.discoverable),
|
||||
key,
|
||||
);
|
||||
@@ -1605,11 +1604,23 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
this.sortedCiphersCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a cipher object.
|
||||
* @param model The cipher view model.
|
||||
* @param cipher The cipher object.
|
||||
* @param key The encryption key to encrypt with. This can be the org key, user key or cipher key, but must never be null
|
||||
*/
|
||||
private async encryptCipher(
|
||||
model: CipherView,
|
||||
cipher: Cipher,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<Cipher> {
|
||||
if (key == null) {
|
||||
throw new Error(
|
||||
"Key to encrypt cipher must not be null. Use the org key, user key or cipher key.",
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.encryptObjProperty(
|
||||
model,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
@@ -61,6 +63,7 @@ export class CollectionService implements CollectionServiceAbstraction {
|
||||
|
||||
constructor(
|
||||
private cryptoService: CryptoService,
|
||||
private encryptService: EncryptService,
|
||||
private i18nService: I18nService,
|
||||
protected stateProvider: StateProvider,
|
||||
) {
|
||||
@@ -101,7 +104,7 @@ export class CollectionService implements CollectionServiceAbstraction {
|
||||
collection.organizationId = model.organizationId;
|
||||
collection.readOnly = model.readOnly;
|
||||
collection.externalId = model.externalId;
|
||||
collection.name = await this.cryptoService.encrypt(model.name, key);
|
||||
collection.name = await this.encryptService.encrypt(model.name, key);
|
||||
return collection;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,13 @@ describe("Folder Service", () => {
|
||||
);
|
||||
encryptService.decryptToUtf8.mockResolvedValue("DEC");
|
||||
|
||||
folderService = new FolderService(cryptoService, i18nService, cipherService, stateProvider);
|
||||
folderService = new FolderService(
|
||||
cryptoService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
cipherService,
|
||||
stateProvider,
|
||||
);
|
||||
|
||||
folderState = stateProvider.activeUser.getFake(FOLDER_ENCRYPTED_FOLDERS);
|
||||
|
||||
@@ -62,9 +68,9 @@ describe("Folder Service", () => {
|
||||
model.id = "2";
|
||||
model.name = "Test Folder";
|
||||
|
||||
cryptoService.encrypt.mockResolvedValue(new EncString("ENC"));
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("ENC"));
|
||||
|
||||
const result = await folderService.encrypt(model);
|
||||
const result = await folderService.encrypt(model, null);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "2",
|
||||
@@ -185,7 +191,7 @@ describe("Folder Service", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
encryptedKey = new EncString("Re-encrypted Folder");
|
||||
cryptoService.encrypt.mockResolvedValue(encryptedKey);
|
||||
encryptService.encrypt.mockResolvedValue(encryptedKey);
|
||||
});
|
||||
|
||||
it("returns re-encrypted user folders", async () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Observable, firstValueFrom, map, shareReplay } from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
|
||||
import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
@@ -25,6 +27,7 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
|
||||
constructor(
|
||||
private cryptoService: CryptoService,
|
||||
private encryptService: EncryptService,
|
||||
private i18nService: I18nService,
|
||||
private cipherService: CipherService,
|
||||
private stateProvider: StateProvider,
|
||||
@@ -48,10 +51,10 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
}
|
||||
|
||||
// TODO: This should be moved to EncryptService or something
|
||||
async encrypt(model: FolderView, key?: SymmetricCryptoKey): Promise<Folder> {
|
||||
async encrypt(model: FolderView, key: SymmetricCryptoKey): Promise<Folder> {
|
||||
const folder = new Folder();
|
||||
folder.id = model.id;
|
||||
folder.name = await this.cryptoService.encrypt(model.name, key);
|
||||
folder.name = await this.encryptService.encrypt(model.name, key);
|
||||
return folder;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { normalizeExpiryYearFormat, isCardExpired } from "@bitwarden/common/vault/utils";
|
||||
|
||||
function getExpiryYearValueFormats(currentCentury: string) {
|
||||
return [
|
||||
[-12, `${currentCentury}12`],
|
||||
[0, `${currentCentury}00`],
|
||||
[2043, "2043"], // valid year with a length of four should be taken directly
|
||||
[24, `${currentCentury}24`],
|
||||
[3054, "3054"], // valid year with a length of four should be taken directly
|
||||
[31423524543, `${currentCentury}43`],
|
||||
[4, `${currentCentury}04`],
|
||||
[null, null],
|
||||
[undefined, null],
|
||||
["-12", `${currentCentury}12`],
|
||||
["", null],
|
||||
["0", `${currentCentury}00`],
|
||||
["00", `${currentCentury}00`],
|
||||
["000", `${currentCentury}00`],
|
||||
["0000", `${currentCentury}00`],
|
||||
["00000", `${currentCentury}00`],
|
||||
["0234234", `${currentCentury}34`],
|
||||
["04", `${currentCentury}04`],
|
||||
["2043", "2043"], // valid year with a length of four should be taken directly
|
||||
["24", `${currentCentury}24`],
|
||||
["3054", "3054"], // valid year with a length of four should be taken directly
|
||||
["31423524543", `${currentCentury}43`],
|
||||
["4", `${currentCentury}04`],
|
||||
["aaaa", null],
|
||||
["adgshsfhjsdrtyhsrth", null],
|
||||
["agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", `${currentCentury}45`],
|
||||
];
|
||||
}
|
||||
|
||||
describe("normalizeExpiryYearFormat", () => {
|
||||
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
|
||||
|
||||
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
|
||||
|
||||
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
|
||||
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
|
||||
const formattedValue = normalizeExpiryYearFormat(inputValue);
|
||||
|
||||
expect(formattedValue).toEqual(expectedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the year 3107", () => {
|
||||
const theDistantFuture = new Date(Date.UTC(3107, 1, 1));
|
||||
jest.spyOn(Date, "now").mockReturnValue(theDistantFuture.valueOf());
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.setSystemTime(theDistantFuture);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const currentCentury = `${new Date(Date.now()).getFullYear()}`.slice(0, 2);
|
||||
expect(currentCentury).toBe("31");
|
||||
|
||||
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
|
||||
|
||||
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
|
||||
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
|
||||
const formattedValue = normalizeExpiryYearFormat(inputValue);
|
||||
|
||||
expect(formattedValue).toEqual(expectedValue);
|
||||
});
|
||||
});
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
});
|
||||
|
||||
function getCardExpiryDateValues() {
|
||||
const currentDate = new Date();
|
||||
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
// `Date` months are zero-indexed, our expiry date month inputs are one-indexed
|
||||
const currentMonth = currentDate.getMonth() + 1;
|
||||
|
||||
return [
|
||||
[null, null, false], // no month, no year
|
||||
[undefined, undefined, false], // no month, no year, invalid values
|
||||
["", "", false], // no month, no year, invalid values
|
||||
["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values
|
||||
["0", `${currentYear - 1}`, true], // invalid 0 month
|
||||
["00", `${currentYear + 1}`, false], // invalid 0 month
|
||||
[`${currentMonth}`, "0000", true], // current month, in the year 2000
|
||||
[null, `${currentYear}`.slice(-2), false], // no month, this year
|
||||
[null, `${currentYear - 1}`.slice(-2), true], // no month, last year
|
||||
["1", null, false], // no year, January
|
||||
["1", `${currentYear - 1}`, true], // January last year
|
||||
["13", `${currentYear}`, false], // 12 + 1 is Feb. in the next year (Date is zero-indexed)
|
||||
[`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired
|
||||
[`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over)
|
||||
[`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over)
|
||||
[`${currentMonth - 1}`, `${currentYear}`, true], // last month
|
||||
[`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now
|
||||
];
|
||||
}
|
||||
|
||||
describe("isCardExpired", () => {
|
||||
const expiryYearValueFormats = getCardExpiryDateValues();
|
||||
|
||||
expiryYearValueFormats.forEach(
|
||||
([inputMonth, inputYear, expectedValue]: [string | null, string | null, boolean]) => {
|
||||
it(`should return ${expectedValue} when the card expiry month is ${inputMonth} and the card expiry year is ${inputYear}`, () => {
|
||||
const testCardView = new CardView();
|
||||
testCardView.expMonth = inputMonth;
|
||||
testCardView.expYear = inputYear;
|
||||
|
||||
const cardIsExpired = isCardExpired(testCardView);
|
||||
|
||||
expect(cardIsExpired).toBe(expectedValue);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
|
||||
type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
|
||||
|
||||
/**
|
||||
* Takes a string or number value and returns a string value formatted as a valid 4-digit year
|
||||
*
|
||||
* @export
|
||||
* @param {(string | number)} yearInput
|
||||
* @return {*} {(Year | null)}
|
||||
*/
|
||||
export function normalizeExpiryYearFormat(yearInput: string | number): Year | null {
|
||||
// The input[type="number"] is returning a number, convert it to a string
|
||||
// An empty field returns null, avoid casting `"null"` to a string
|
||||
const yearInputIsEmpty = yearInput == null || yearInput === "";
|
||||
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
|
||||
|
||||
// Exit early if year is already formatted correctly or empty
|
||||
if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) {
|
||||
return expirationYear as Year;
|
||||
}
|
||||
|
||||
expirationYear = expirationYear
|
||||
// For safety, because even input[type="number"] will allow decimals
|
||||
.replace(/[^\d]/g, "")
|
||||
// remove any leading zero padding (leave the last leading zero if it ends the string)
|
||||
.replace(/^[0]+(?=.)/, "");
|
||||
|
||||
if (expirationYear === "") {
|
||||
expirationYear = null;
|
||||
}
|
||||
|
||||
// given the context of payment card expiry, a year character length of 3, or over 4
|
||||
// is more likely to be a mistake than an intentional value for the far past or far future.
|
||||
if (expirationYear && expirationYear.length !== 4) {
|
||||
const paddedYear = ("00" + expirationYear).slice(-2);
|
||||
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
|
||||
|
||||
expirationYear = currentCentury + paddedYear;
|
||||
}
|
||||
|
||||
return expirationYear as Year | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
|
||||
* the card is expired.
|
||||
*
|
||||
* @export
|
||||
* @param {CardView} cipherCard
|
||||
* @return {*} {boolean}
|
||||
*/
|
||||
export function isCardExpired(cipherCard: CardView): boolean {
|
||||
if (cipherCard) {
|
||||
const { expMonth = null, expYear = null } = cipherCard;
|
||||
|
||||
const now = new Date();
|
||||
const normalizedYear = normalizeExpiryYearFormat(expYear);
|
||||
|
||||
// If the card year is before the current year, don't bother checking the month
|
||||
if (normalizedYear && parseInt(normalizedYear) < now.getFullYear()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedYear && expMonth) {
|
||||
// `Date` months are zero-indexed
|
||||
const parsedMonth =
|
||||
parseInt(expMonth) - 1 ||
|
||||
// Add a month floor of 0 to protect against an invalid low month value of "0"
|
||||
0;
|
||||
|
||||
const parsedYear = parseInt(normalizedYear);
|
||||
|
||||
// First day of the next month minus one, to get last day of the card month
|
||||
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 0);
|
||||
|
||||
return cardExpiry < now;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user