Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
164 lines
4.3 KiB
TypeScript
164 lines
4.3 KiB
TypeScript
import { Base64 } from '@rocket.chat/base64';
|
|
|
|
import { Binary } from './binary';
|
|
import type { ICodec } from './codec';
|
|
import * as Pbkdf2 from './crypto/pbkdf2';
|
|
import { randomUUID } from './crypto/shared';
|
|
|
|
/**
|
|
* Version 1 format:
|
|
* ```
|
|
* json({ $binary: base64(iv[16] + ciphertext) })
|
|
* ```
|
|
*/
|
|
interface IStoredKeyV1 {
|
|
/**
|
|
* Base64-encoded binary data
|
|
* - first 16 bytes are the IV
|
|
* - remaining bytes are the ciphertext
|
|
*/
|
|
$binary: string;
|
|
}
|
|
/**
|
|
* Version 2 format:
|
|
* ```typescript
|
|
* json({ iv: base64(iv[12]), ciphertext: base64(data[...]), salt: string(), iterations: number() })
|
|
* ```
|
|
*/
|
|
interface IStoredKeyV2 {
|
|
iv: string;
|
|
ciphertext: string;
|
|
salt: string;
|
|
iterations: number;
|
|
}
|
|
|
|
type StoredKey = IStoredKeyV1 | IStoredKeyV2;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
const StoredKey: ICodec<string, StoredKey> = {
|
|
decode: (data) => {
|
|
const json: unknown = JSON.parse(data);
|
|
|
|
if (typeof json !== 'object' || json === null) {
|
|
throw new TypeError('Invalid private key format');
|
|
}
|
|
|
|
if ('$binary' in json && typeof json.$binary === 'string') {
|
|
return { $binary: json.$binary } satisfies IStoredKeyV1;
|
|
}
|
|
|
|
if (
|
|
'iv' in json &&
|
|
typeof json.iv === 'string' &&
|
|
'ciphertext' in json &&
|
|
typeof json.ciphertext === 'string' &&
|
|
'salt' in json &&
|
|
typeof json.salt === 'string' &&
|
|
'iterations' in json &&
|
|
typeof json.iterations === 'number'
|
|
) {
|
|
return { iv: json.iv, ciphertext: json.ciphertext, salt: json.salt, iterations: json.iterations } satisfies IStoredKeyV2;
|
|
}
|
|
|
|
throw new TypeError('Invalid private key format');
|
|
},
|
|
encode: (data) => JSON.stringify(data),
|
|
};
|
|
|
|
type EncryptedKeyContent = {
|
|
iv: Uint8Array<ArrayBuffer>;
|
|
ciphertext: Uint8Array<ArrayBuffer>;
|
|
};
|
|
|
|
type EncryptedKeyOptions = {
|
|
salt: string;
|
|
iterations: number;
|
|
};
|
|
|
|
type EncryptedKey = {
|
|
content: EncryptedKeyContent;
|
|
options: EncryptedKeyOptions;
|
|
};
|
|
|
|
class EncryptedKeyCodec implements ICodec<string, EncryptedKey, IStoredKeyV2> {
|
|
userId: string;
|
|
|
|
constructor(userId: string) {
|
|
this.userId = userId;
|
|
}
|
|
|
|
encode(encryptedKey: EncryptedKey): IStoredKeyV2 {
|
|
return {
|
|
iv: Base64.encode(encryptedKey.content.iv),
|
|
ciphertext: Base64.encode(encryptedKey.content.ciphertext),
|
|
salt: encryptedKey.options.salt,
|
|
iterations: encryptedKey.options.iterations,
|
|
};
|
|
}
|
|
|
|
decode(storedKey: string): EncryptedKey {
|
|
const storedKeyObj = StoredKey.decode(storedKey);
|
|
if ('$binary' in storedKeyObj) {
|
|
// v1
|
|
const binary = Base64.decode(storedKeyObj.$binary);
|
|
|
|
return {
|
|
content: { iv: binary.slice(0, 16), ciphertext: binary.slice(16) },
|
|
options: {
|
|
salt: this.userId,
|
|
iterations: 1000,
|
|
},
|
|
};
|
|
}
|
|
|
|
// v2
|
|
const { iv, ciphertext, salt, iterations } = storedKeyObj;
|
|
return {
|
|
content: {
|
|
iv: Base64.decode(iv),
|
|
ciphertext: Base64.decode(ciphertext),
|
|
},
|
|
options: {
|
|
salt,
|
|
iterations,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export class Keychain {
|
|
private readonly userId: string;
|
|
|
|
private readonly codec: EncryptedKeyCodec;
|
|
|
|
constructor(userId: string) {
|
|
this.userId = userId;
|
|
this.codec = new EncryptedKeyCodec(userId);
|
|
}
|
|
|
|
async decryptKey(privateKey: string, password: string): Promise<string> {
|
|
const { content, options } = this.codec.decode(privateKey);
|
|
const algorithm = content.iv.length === 16 ? 'AES-CBC' : 'AES-GCM';
|
|
const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password)));
|
|
const derivedBits = await Pbkdf2.derive(baseKey, {
|
|
salt: new Uint8Array(Binary.decode(options.salt)),
|
|
iterations: options.iterations,
|
|
});
|
|
const key = await Pbkdf2.importKey(derivedBits, { name: algorithm, length: 256 });
|
|
const decrypted = await Pbkdf2.decrypt(key, content);
|
|
return Binary.encode(decrypted.buffer);
|
|
}
|
|
|
|
async encryptKey(privateKey: string, password: string): Promise<IStoredKeyV2> {
|
|
const salt = `v2:${this.userId}:${randomUUID()}`;
|
|
const iterations = 100_000;
|
|
const algorithm = 'AES-GCM';
|
|
const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password)));
|
|
const derivedBits = await Pbkdf2.derive(baseKey, { salt: new Uint8Array(Binary.decode(salt)), iterations });
|
|
const key = await Pbkdf2.importKey(derivedBits, { name: algorithm, length: 256 });
|
|
const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.decode(privateKey)));
|
|
|
|
return this.codec.encode({ content, options: { salt, iterations } });
|
|
}
|
|
}
|