mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-23 03:25:12 +01:00
WIP
This commit is contained in:
parent
8cf7a1b161
commit
67ad975377
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user