This commit is contained in:
Bruno Windels 2023-04-06 14:51:38 +02:00
parent 8cf7a1b161
commit 67ad975377

View File

@ -17,11 +17,13 @@ import {BaseObservableValue, RetainedObservableValue} from "../../observable/val
import {KeyType} from "./index"; import {KeyType} from "./index";
import {keyFromPassphrase} from "./passphrase"; import {keyFromPassphrase} from "./passphrase";
import {keyFromRecoveryKey} from "./recoveryKey"; import {keyFromRecoveryKey} from "./recoveryKey";
import type {Key, KeyDescription} from "./common"; import {Key, KeyDescription, KeyDescriptionData} from "./common";
import type {Platform} from "../../platform/web/Platform.js"; import type {Platform} from "../../platform/web/Platform.js";
import type {Transaction} from "../storage/idb/Transaction"; import type {Transaction} from "../storage/idb/Transaction";
import type {Storage} from "../storage/idb/Storage"; import type {Storage} from "../storage/idb/Storage";
import type {AccountDataEntry} from "../storage/idb/stores/AccountDataStore"; import type {AccountDataEntry} from "../storage/idb/stores/AccountDataStore";
import type * as OlmNamespace from "@matrix-org/olm";
type Olm = typeof OlmNamespace;
type EncryptedData = { type EncryptedData = {
iv: string; iv: string;
@ -30,6 +32,7 @@ type EncryptedData = {
} }
export enum DecryptionFailure { export enum DecryptionFailure {
NoKey = 1,
NotEncryptedWithKey, NotEncryptedWithKey,
BadMAC, BadMAC,
UnsupportedAlgorithm, UnsupportedAlgorithm,
@ -39,9 +42,11 @@ class DecryptionError extends Error {
constructor(msg: string, public readonly reason: DecryptionFailure) { constructor(msg: string, public readonly reason: DecryptionFailure) {
super(msg); super(msg);
} }
}
type AccountData = {type: string, content: Record<string, any>}; toString() {
return `${this.constructor.name}: ${super.message}: ${this.reason}`
}
}
type KeyCredentials = { type KeyCredentials = {
type: KeyType, type: KeyType,
@ -50,21 +55,22 @@ type KeyCredentials = {
export class SecretStorage { export class SecretStorage {
// we know the id but don't have the description yet // we know the id but don't have the description yet
private _keyId?: string; private keyId?: string;
// we have the description but not the credentials yet // we have the description but not the credentials yet
private _keyDescription?: KeyDescription; private keyDescription?: KeyDescription;
// we have the credentials but not the id or description yet // we have the credentials but not the id or description yet
private _keyCredentials?: KeyCredentials; private keyCredentials?: KeyCredentials;
// we have everything to compose a valid key // we have everything to compose a valid key
private _key?: Key; private key?: Key;
private readonly _platform: Platform; private readonly platform: Platform;
private readonly _storage: Storage; private readonly storage: Storage;
private observedSecrets: Map<string, RetainedObservableValue<string | undefined>>; private observedSecrets: Map<string, RetainedObservableValue<string | undefined>>;
private readonly olm: Olm;
constructor({key, platform, storage}: {key: Key, platform: Platform, storage: Storage}) { constructor({platform, storage, olm}: {platform: Platform, storage: Storage, olm: Olm}) {
this._key = key; this.platform = platform;
this._platform = platform; this.storage = storage;
this._storage = storage; this.olm = olm;
this.observedSecrets = new Map();
} }
load() { load() {
@ -73,63 +79,179 @@ export class SecretStorage {
async setKey(type: KeyType, credential: string) { async setKey(type: KeyType, credential: string) {
const credentials: KeyCredentials = {type, credential}; const credentials: KeyCredentials = {type, credential};
this._keyCredentials = credentials; this.keyCredentials = credentials;
this.updateKey(this._keyDescription, this._keyCredentials); this.updateKey(this.keyDescription, this.keyCredentials);
} }
async setKeyWithDehydratedDeviceKey() { async setKeyWithDehydratedDeviceKey(dehydrationKey: Key): Promise<boolean> {
const {keyDescription} = this;
if (!keyDescription) {
return false;
}
if (await keyDescription.isCompatible(dehydrationKey, this.platform)) {
const key = dehydrationKey.withDescription(keyDescription);
this.key = key;
return true;
}
return false;
} }
private async updateKey(keyDescription: KeyDescription | undefined, credentials: KeyCredentials | undefined, txn: Transaction) { private async updateKey(keyDescription: KeyDescription | undefined, credentials: KeyCredentials | undefined): Promise<boolean> {
if (keyDescription && credentials) { if (keyDescription && credentials) {
if (credentials.type === KeyType.Passphrase) { if (credentials.type === KeyType.Passphrase) {
this._key = await keyFromPassphrase(keyDescription, credentials.credential, this._platform); this.key = await keyFromPassphrase(keyDescription, credentials.credential, this.platform);
return true;
} else if (credentials.type === KeyType.RecoveryKey) { } else if (credentials.type === KeyType.RecoveryKey) {
this._key = await keyFromRecoveryKey(keyDescription, credentials.credential, this._olm, this._platform); this.key = await keyFromRecoveryKey(keyDescription, credentials.credential, this.olm, this.platform);
return true;
} }
//
} }
return false;
} }
private update(keyDescription: KeyDescription, credentials: KeyCredentials) { async writeSync(accountData: ReadonlyArray<AccountDataEntry>, txn: Transaction): Promise<{newKey?: KeyDescription, accountData: ReadonlyArray<AccountDataEntry>}> {
}
writeSync(accountData: ReadonlyArray<AccountData>, txn: Transaction, log: ILogItem): Promise<void> {
const newDefaultKey = accountData.find(e => e.type === "m.secret_storage.default_key"); const newDefaultKey = accountData.find(e => e.type === "m.secret_storage.default_key");
const keyId: string | undefined = newDefaultKey ? newDefaultKey.content?.key : this._keyId; const keyId: string | undefined = newDefaultKey ? newDefaultKey.content?.key : this.keyId;
const keyEventType = keyId ? `m.secret_storage.key.${keyId}` : undefined; const keyEventType = keyId ? `m.secret_storage.key.${keyId}` : undefined;
let newKey = keyEventType ? accountData.find(e => e.type === keyEventType) : undefined; let newKeyData = keyEventType ? accountData.find(e => e.type === keyEventType) : undefined;
if (newDefaultKey && keyEventType && !newKey) { // if the default key was changed but the key itself wasn't in the sync, get it from storage
newKey = await txn.accountData.get(keyEventType); if (newDefaultKey && keyEventType && !newKeyData) {
newKeyData = await txn.accountData.get(keyEventType);
} }
if (newKey) { let newKey: KeyDescription | undefined;
this.setKeyDescription() if (newKeyData && keyId) {
newKey = new KeyDescription(keyId, newKeyData.content as KeyDescriptionData);
} }
const keyChanged = !!newDefaultKey || !!newKey; return {
if (keyChanged) { newKey,
// update all values accountData
} else { };
for(const event of accountData) { }
} afterSync({newKey, accountData}: {newKey?: KeyDescription, accountData: ReadonlyArray<AccountDataEntry>}): void {
if (newKey) {
this.updateKeyAndAllValues(newKey);
} else if (this.key) {
const observedValues = accountData.filter(a => this.observedSecrets.has(a.type));
Promise.all(observedValues.map(async entry => {
const observable = this.observedSecrets.get(entry.type)!;
const secret = await this.decryptAccountData(entry);
observable.set(secret);
})).then(undefined, reason => {
this.platform.logger.log("SecretStorage.afterSync: decryption failed").catch(reason);
});
} }
} }
afterSync(): void { observeSecret(name: string): BaseObservableValue<string | undefined> {
const existingObservable = this.observedSecrets.get(name);
if (existingObservable) {
return existingObservable;
}
const observable: RetainedObservableValue<string | undefined> = new RetainedObservableValue(undefined, () => {
this.observedSecrets.delete(name);
});
this.observedSecrets.set(name, observable);
this.readSecret(name).then(secret => {
observable.set(secret);
});
return observable;
} }
/** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */ /** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */
async hasValidKeyForAnyAccountData() { async readSecret(name: string): Promise<string | undefined> {
const txn = await this._storage.readTxn([ const txn = await this.storage.readTxn([
this._storage.storeNames.accountData, this.storage.storeNames.accountData,
]);
const accountData = await txn.accountData.get(name);
if (!accountData) {
return;
}
try {
return await this.decryptAccountData(accountData);
} catch (err) {
this.platform.logger.log({l: "SecretStorage.readSecret: failed to read secret", id: name}).catch(err);
return undefined;
}
}
private async decryptAccountData(accountData: AccountDataEntry): Promise<string | undefined> {
if (!this.key) {
throw new DecryptionError("No key set", DecryptionFailure.NoKey);
}
const encryptedData = accountData?.content?.encrypted?.[this.key.id] as EncryptedData;
if (!encryptedData) {
throw new DecryptionError(`Secret ${accountData.type} is not encrypted for key ${this.key.id}`, DecryptionFailure.NotEncryptedWithKey);
}
if (this.key.algorithm === "m.secret_storage.v1.aes-hmac-sha2") {
return await this.decryptAESSecret(accountData.type, encryptedData, this.key.binaryKey);
} else {
throw new DecryptionError(`Unsupported algorithm for key ${this.key.id}: ${this.key.algorithm}`, DecryptionFailure.UnsupportedAlgorithm);
}
}
private async decryptAESSecret(type: string, encryptedData: EncryptedData, binaryKey: Uint8Array): Promise<string> {
const {base64, utf8} = this.platform.encoding;
// now derive the aes and mac key from the 4s key
const hkdfKey = await this.platform.crypto.derive.hkdf(
binaryKey,
new Uint8Array(8).buffer, //zero salt
utf8.encode(type), // info
"SHA-256",
512 // 512 bits or 64 bytes
);
const aesKey = hkdfKey.slice(0, 32);
const hmacKey = hkdfKey.slice(32);
const ciphertextBytes = base64.decode(encryptedData.ciphertext);
const isVerified = await this.platform.crypto.hmac.verify(
hmacKey, base64.decode(encryptedData.mac),
ciphertextBytes, "SHA-256");
if (!isVerified) {
throw new DecryptionError("Bad MAC", DecryptionFailure.BadMAC);
}
const plaintextBytes = await this.platform.crypto.aes.decryptCTR({
key: aesKey,
iv: base64.decode(encryptedData.iv),
data: ciphertextBytes
});
return utf8.decode(plaintextBytes);
}
private async updateKeyAndAllValues(newKey: KeyDescription) {
this.keyDescription = newKey;
if (await this.updateKey(this.keyDescription, this.keyCredentials)) {
const valuesToUpdate = Array.from(this.observedSecrets.keys());
const txn = await this.storage.readTxn([this.storage.storeNames.accountData]);
const entries = await Promise.all(valuesToUpdate.map(type => txn.accountData.get(type)));
const foundEntries = entries.filter(e => !!e) as ReadonlyArray<AccountDataEntry>;
this.decryptAndUpdateEntries(foundEntries);
}
}
private decryptAndUpdateEntries(entries: ReadonlyArray<AccountDataEntry>): void {
Promise.all(entries.map(async entry => {
const observable = this.observedSecrets.get(entry.type)!;
const secret = await this.decryptAccountData(entry);
observable.set(secret);
})).then(undefined, reason => {
this.platform.logger.log("SecretStorage.afterSync: decryption failed").catch(reason);
});
}
/** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */
private async hasValidKeyForAnyAccountData() {
const txn = await this.storage.readTxn([
this.storage.storeNames.accountData,
]); ]);
const allAccountData = await txn.accountData.getAll(); const allAccountData = await txn.accountData.getAll();
for (const accountData of allAccountData) { for (const accountData of allAccountData) {
try { try {
const secret = await this._decryptAccountData(accountData); const secret = await this.decryptAccountData(accountData);
return true; // decryption succeeded return true; // decryption succeeded
} catch (err) { } catch (err) {
if (err instanceof DecryptionError && err.reason !== DecryptionFailure.NotEncryptedWithKey) { if (err instanceof DecryptionError && err.reason !== DecryptionFailure.NotEncryptedWithKey) {
@ -141,64 +263,4 @@ export class SecretStorage {
} }
return false; return false;
} }
observeSecret(name: string): BaseObservableValue<string | undefined> {
}
/** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */
async readSecret(name: string): Promise<string | undefined> {
const txn = await this._storage.readTxn([
this._storage.storeNames.accountData,
]);
const accountData = await txn.accountData.get(name);
if (!accountData) {
return;
}
return await this._decryptAccountData(accountData);
}
async _decryptAccountData(accountData: AccountDataEntry): Promise<string> {
const encryptedData = accountData?.content?.encrypted?.[this._key.id] as EncryptedData;
if (!encryptedData) {
throw new DecryptionError(`Secret ${accountData.type} is not encrypted for key ${this._key.id}`, DecryptionFailure.NotEncryptedWithKey);
}
if (this._key.algorithm === "m.secret_storage.v1.aes-hmac-sha2") {
return await this._decryptAESSecret(accountData.type, encryptedData);
} else {
throw new DecryptionError(`Unsupported algorithm for key ${this._key.id}: ${this._key.algorithm}`, DecryptionFailure.UnsupportedAlgorithm);
}
}
async _decryptAESSecret(type: string, encryptedData: EncryptedData): Promise<string> {
const {base64, utf8} = this._platform.encoding;
// now derive the aes and mac key from the 4s key
const hkdfKey = await this._platform.crypto.derive.hkdf(
this._key.binaryKey,
new Uint8Array(8).buffer, //zero salt
utf8.encode(type), // info
"SHA-256",
512 // 512 bits or 64 bytes
);
const aesKey = hkdfKey.slice(0, 32);
const hmacKey = hkdfKey.slice(32);
const ciphertextBytes = base64.decode(encryptedData.ciphertext);
const isVerified = await this._platform.crypto.hmac.verify(
hmacKey, base64.decode(encryptedData.mac),
ciphertextBytes, "SHA-256");
if (!isVerified) {
throw new DecryptionError("Bad MAC", DecryptionFailure.BadMAC);
}
const plaintextBytes = await this._platform.crypto.aes.decryptCTR({
key: aesKey,
iv: base64.decode(encryptedData.iv),
data: ciphertextBytes
});
return utf8.decode(plaintextBytes);
}
} }