mirror of
https://github.com/bitwarden/browser
synced 2025-12-20 02:03:39 +00:00
[PM-5614] introduce SecretState wrapper (#7823)
Matt provided a ton of help on getting the state interactions right. Both he and Justin collaborated with me to write the core of of the secret classifier. Co-authored-by: Matt Gibson <mgibson@bitwarden.com> Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { DefaultOptions, Forwarders, SecretPadding } from "./constants";
|
||||
import { DefaultOptions, Forwarders } from "./constants";
|
||||
import { ApiOptions, ForwarderId } from "./forwarder-options";
|
||||
import { MaybeLeakedOptions, UsernameGeneratorOptions } from "./generator-options";
|
||||
|
||||
@@ -73,116 +70,3 @@ export function falsyDefault<T>(value: T, defaults: Partial<T>): T {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** encrypts sensitive options and stores them in-place.
|
||||
* @param encryptService The service used to encrypt the options.
|
||||
* @param key The key used to encrypt the options.
|
||||
* @param options The options to encrypt. The encrypted members are
|
||||
* removed from the options and the decrypted members
|
||||
* are added to the options.
|
||||
*/
|
||||
export async function encryptInPlace(
|
||||
encryptService: EncryptService,
|
||||
key: SymmetricCryptoKey,
|
||||
options: ApiOptions & MaybeLeakedOptions,
|
||||
) {
|
||||
if (!options.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pick the options that require encryption
|
||||
const encryptOptions = (({ token, wasPlainText }) => ({ token, wasPlainText }))(options);
|
||||
delete options.token;
|
||||
delete options.wasPlainText;
|
||||
|
||||
// don't leak whether a leak was possible by padding the encrypted string.
|
||||
// without this, it could be possible to determine whether the token was
|
||||
// encrypted by checking the length of the encrypted string.
|
||||
const toEncrypt = JSON.stringify(encryptOptions).padEnd(
|
||||
SecretPadding.length,
|
||||
SecretPadding.character,
|
||||
);
|
||||
|
||||
const encrypted = await encryptService.encrypt(toEncrypt, key);
|
||||
options.encryptedToken = encrypted;
|
||||
}
|
||||
|
||||
/** decrypts sensitive options and stores them in-place.
|
||||
* @param encryptService The service used to decrypt the options.
|
||||
* @param key The key used to decrypt the options.
|
||||
* @param options The options to decrypt. The encrypted members are
|
||||
* removed from the options and the decrypted members
|
||||
* are added to the options.
|
||||
* @returns null if the options were decrypted successfully, otherwise
|
||||
* a string describing why the options could not be decrypted.
|
||||
* The return values are intended to be used for logging and debugging.
|
||||
* @remarks This method does not throw if the options could not be decrypted
|
||||
* because in such cases there's nothing the user can do to fix it.
|
||||
*/
|
||||
export async function decryptInPlace(
|
||||
encryptService: EncryptService,
|
||||
key: SymmetricCryptoKey,
|
||||
options: ApiOptions & MaybeLeakedOptions,
|
||||
) {
|
||||
if (!options.encryptedToken) {
|
||||
return "missing encryptedToken";
|
||||
}
|
||||
|
||||
const decrypted = await encryptService.decryptToUtf8(options.encryptedToken, key);
|
||||
delete options.encryptedToken;
|
||||
|
||||
// If the decrypted string is not exactly the padding length, it could be compromised
|
||||
// and shouldn't be trusted.
|
||||
if (decrypted.length !== SecretPadding.length) {
|
||||
return "invalid length";
|
||||
}
|
||||
|
||||
// JSON terminates with a closing brace, after which the plaintext repeats `character`
|
||||
// If the closing brace is not found, then it could be compromised and shouldn't be trusted.
|
||||
const jsonBreakpoint = decrypted.lastIndexOf("}") + 1;
|
||||
if (jsonBreakpoint < 1) {
|
||||
return "missing json object";
|
||||
}
|
||||
|
||||
// If the padding contains invalid padding characters then the padding could be used
|
||||
// as a side channel for arbitrary data.
|
||||
if (decrypted.substring(jsonBreakpoint).match(SecretPadding.hasInvalidPadding)) {
|
||||
return "invalid padding";
|
||||
}
|
||||
|
||||
// remove padding and parse the JSON
|
||||
const json = decrypted.substring(0, jsonBreakpoint);
|
||||
|
||||
const { decryptedOptions, error } = parseOptions(json);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
Object.assign(options, decryptedOptions);
|
||||
}
|
||||
|
||||
function parseOptions(json: string) {
|
||||
let decryptedOptions = null;
|
||||
try {
|
||||
decryptedOptions = JSON.parse(json);
|
||||
} catch {
|
||||
return { decryptedOptions: undefined as string, error: "invalid json" };
|
||||
}
|
||||
|
||||
// If the decrypted options contain any property that is not in the original
|
||||
// options, then the object could be used as a side channel for arbitrary data.
|
||||
if (Object.keys(decryptedOptions).some((key) => key !== "token" && key !== "wasPlainText")) {
|
||||
return { decryptedOptions: undefined as string, error: "unknown keys" };
|
||||
}
|
||||
|
||||
// If the decrypted properties are not the expected type, then the object could
|
||||
// be compromised and shouldn't be trusted.
|
||||
if (typeof decryptedOptions.token !== "string") {
|
||||
return { decryptedOptions: undefined as string, error: "invalid token" };
|
||||
}
|
||||
if (decryptedOptions.wasPlainText !== undefined && decryptedOptions.wasPlainText !== true) {
|
||||
return { decryptedOptions: undefined as string, error: "invalid wasPlainText" };
|
||||
}
|
||||
|
||||
return { decryptedOptions, error: undefined as string };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user