From c91c3b0ca8cbee211e700f3efce9342fa4a431e5 Mon Sep 17 00:00:00 2001 From: std-odoo Date: Wed, 15 Jul 2026 08:45:13 +0200 Subject: [PATCH] [IMP] gmail: encrypt the database with the `sub` of the Gmail token Purpose ======= Encrypt the database with the `sub` of the Gmail token, to reduce the damage in case of a database leak. Technical ========= We don't even need to store the email anymore (the hash of the key is all we need). What's not encrypted is - user_id, for the foreign key between the log and the users - create_date, so the CRON can clean the old log Nor of those information are really important, and we won't be able to link them to a specific Odoo / Gmail user without the encryption key. The tricky point is the Odoo authentication process, the user is redirected to Odoo to accept the plugin, and then is redirected to the plugin with the Odoo token in the URL. We need to save the Odoo token in database, and so we need the encryption key. So we derive an application key from the application secret (for convenience, so there's no restriction on application secret size), and we encrypt with the application key, the user's key, user's email, login token, etc. That way those informations do not appear in the URL (and his browser history). Task-6366629 --- gmail/README.md | 4 + gmail/init_db.sql | 35 +++++--- gmail/package.json | 3 +- gmail/src/index.ts | 22 +++-- gmail/src/models/email.ts | 25 ++++-- gmail/src/models/user.ts | 127 +++++++++++++++++----------- gmail/src/services/odoo_auth.ts | 32 +++++-- gmail/src/utils/encrypt.ts | 106 +++++++++++++++++++++++ gmail/src/views/debug.ts | 1 - gmail/tests/encrypt.test.ts | 145 ++++++++++++++++++++++++++++++++ 10 files changed, 413 insertions(+), 87 deletions(-) create mode 100644 gmail/src/utils/encrypt.ts create mode 100644 gmail/tests/encrypt.test.ts diff --git a/gmail/README.md b/gmail/README.md index d569554a6..c3e12efab 100644 --- a/gmail/README.md +++ b/gmail/README.md @@ -30,3 +30,7 @@ Before committing, run prettier Update the `CLIENT_ID` and the public URL in `consts.ts`, then run > npm run build > node dist + +# Run tests +Run the following command to run unit tests +> npm run test diff --git a/gmail/init_db.sql b/gmail/init_db.sql index ea4f59a97..e2dd3cfa6 100644 --- a/gmail/init_db.sql +++ b/gmail/init_db.sql @@ -8,23 +8,32 @@ -- createdb odoo_gmail_addin -- psql -f init_db.sql odoo_gmail_addin -CREATE TABLE IF NOT EXISTS users_settings ( +CREATE TABLE IF NOT EXISTS enc_users_settings ( id SERIAL PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - odoo_url TEXT, - odoo_token TEXT, - login_token TEXT, - login_token_expire_at TIMESTAMP WITH TIME ZONE, - translations JSON, - translations_expire_at TIMESTAMP WITH TIME ZONE + + -- hash(derived key), used to retrieve the user's settings + key_hash bytea NOT NULL UNIQUE, + CONSTRAINT users_settings_key_hash_length_check CHECK (octet_length(key_hash) = 32), + + enc_odoo_url bytea, + enc_odoo_token bytea, + enc_translations bytea, + enc_translations_expire_at bytea, + + -- temporary value used during the Odoo authentication process + enc_login_token bytea, + enc_login_token_expire_at bytea ); -CREATE TABLE IF NOT EXISTS email_logs ( +CREATE TABLE IF NOT EXISTS enc_email_logs ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, - message_id TEXT NOT NULL, - res_id INTEGER NOT NULL, - res_model TEXT NOT NULL, + message_id_hash bytea NOT NULL, + enc_res_id bytea NOT NULL, + enc_res_model bytea NOT NULL, + -- not encrypted to clean in the CRON create_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - FOREIGN KEY (user_id) REFERENCES users_settings(id) ON DELETE CASCADE + FOREIGN KEY (user_id) REFERENCES enc_users_settings(id) ON DELETE CASCADE ); + +CREATE INDEX IF NOT EXISTS enc_email_logs_user_id_idx ON enc_email_logs(user_id); diff --git a/gmail/package.json b/gmail/package.json index 849828cb1..c30f4fffc 100644 --- a/gmail/package.json +++ b/gmail/package.json @@ -8,7 +8,8 @@ "build": "rm -rf dist && tsc && npm run gen-secret && mv .env dist/.env", "start": "cd dist && node index.js", "prettier": "prettier --write 'src/**/*.ts'", - "gen-secret": "echo APP_SECRET=$(node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\") > .env" + "gen-secret": "echo APP_SECRET=$(node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\") > .env", + "test": "node --import=tsx --test tests/*.test.ts" }, "author": "", "license": "", diff --git a/gmail/src/index.ts b/gmail/src/index.ts index f6fa495a6..97899692e 100644 --- a/gmail/src/index.ts +++ b/gmail/src/index.ts @@ -5,6 +5,7 @@ import { google } from "googleapis"; import jwt from "jsonwebtoken"; import cron from "node-cron"; import path from "path"; +import { HOST } from "./consts"; import { Email } from "./models/email"; import { Partner } from "./models/partner"; import { State } from "./models/state"; @@ -38,7 +39,7 @@ cron.schedule("0 0 * * *", async () => { console.log("Clean the email logging table..."); await pool.query( ` - DELETE FROM email_logs + DELETE FROM enc_email_logs WHERE create_date < NOW() - INTERVAL '1 month' `, ); @@ -143,10 +144,6 @@ app.post( app.post( "/execute_action", asyncHandler(async (req, res) => { - const user = await User.getUserFromGoogleToken(req.body); - - const _t = await Translate.getTranslations(user); - const rawFormInputs = req.body.commonEventObject.formInputs || {}; const formInputs = Object.fromEntries( Object.entries(rawFormInputs).map(([key, value]) => [ @@ -169,6 +166,9 @@ app.post( state.email.accessToken = req.body.gmail.accessToken; } + const user = await User.getUserFromGoogleToken(req.body); + const _t = await Translate.getTranslations(user); + const result = await getEventHandler(functionName)(state, _t, user, args, formInputs); res.json(result.build()); }), @@ -269,10 +269,18 @@ app.use( * Route that we can call to check that the database is correctly configured. */ app.use("/db_check", async (req, res, next) => { + try { + new URL(HOST); + } catch (e) { + console.error(e); + res.json("Invalid host URL"); + return; + } + try { // check that the table exists - await pool.query("SELECT id FROM users_settings LIMIT 1"); - await pool.query("SELECT id FROM email_logs LIMIT 1"); + await pool.query("SELECT id FROM enc_users_settings LIMIT 1"); + await pool.query("SELECT id FROM enc_email_logs LIMIT 1"); res.json("ok"); } catch (e) { console.error(e); diff --git a/gmail/src/models/email.ts b/gmail/src/models/email.ts index d202de2e8..fab9d95e7 100644 --- a/gmail/src/models/email.ts +++ b/gmail/src/models/email.ts @@ -3,6 +3,7 @@ import { google } from "googleapis"; import { simpleParser } from "mailparser"; import { ErrorMessage } from "../models/error_message"; import pool from "../utils/db"; +import { decryptAesGcm, encryptAesGcm, hmacSha256 } from "../utils/encrypt"; import { User } from "./user"; const gmail = google.gmail({ version: "v1" }); @@ -253,12 +254,19 @@ export class Email { async setLoggingState(user: User, resModel: string, resId: number) { console.log(`Logging email for user ${user.email}`); this.loggingState[resModel].push(resId); + + const enc = (pt) => encryptAesGcm(pt, user.encryptionKey); await pool.query( ` - INSERT INTO email_logs (user_id, message_id, res_id, res_model) + INSERT INTO enc_email_logs (user_id, message_id_hash, enc_res_id, enc_res_model) VALUES ($1, $2, $3, $4) `, - [user.id, this.messageId, resId, resModel], + [ + user.id, + hmacSha256(user.encryptionKey, `Email log ${this.messageId}`), + enc(resId.toString()), + enc(resModel), + ], ); } @@ -281,14 +289,17 @@ export class Email { user: User, messageId: string, ): Promise> { + const dec = (ct) => decryptAesGcm(ct, user.encryptionKey); + const result = await pool.query( ` - SELECT res_model, res_id - FROM email_logs - WHERE user_id = $1 AND message_id = $2 + SELECT enc_res_model, enc_res_id + FROM enc_email_logs + WHERE user_id = $1 AND message_id_hash = $2 `, - [user.id, messageId], + [user.id, hmacSha256(user.encryptionKey, `Email log ${messageId}`)], ); + const ret: Record = { "res.partner": [], "crm.lead": [], @@ -296,7 +307,7 @@ export class Email { "project.task": [], }; for (const row of result.rows) { - ret[row.res_model].push(row.res_id); + ret[dec(row.enc_res_model)].push(parseInt(dec(row.enc_res_id))); } return ret; } diff --git a/gmail/src/models/user.ts b/gmail/src/models/user.ts index 9f8978701..cf03a5218 100644 --- a/gmail/src/models/user.ts +++ b/gmail/src/models/user.ts @@ -2,9 +2,14 @@ import * as crypto from "crypto"; import { OAuth2Client } from "google-auth-library"; import { CLIENT_ID } from "../consts"; import pool from "../utils/db"; +import { decryptAesGcm, deriveKeyScryptCached, encryptAesGcm, hmacSha256 } from "../utils/encrypt"; + +const oAuth2Client = new OAuth2Client(); export class User { id?: number; + encryptionKey: Buffer; + email: string; odooUrl?: string; odooToken?: string; @@ -21,6 +26,7 @@ export class User { constructor( id: number, + encryptionKey: Buffer, email: string, odooUrl?: string, odooToken?: string, @@ -30,6 +36,7 @@ export class User { translationsExpireAt?: Date, ) { this.id = id; + this.encryptionKey = encryptionKey; this.email = email; this.odooUrl = odooUrl; this.odooToken = odooToken; @@ -41,34 +48,37 @@ export class User { async save() { console.log(`Saving user ${this.email}`); + + const enc = (pt) => encryptAesGcm(pt, this.encryptionKey); await pool.query( ` - INSERT INTO users_settings ( - email, - odoo_url, - odoo_token, - login_token, - login_token_expire_at, - translations, - translations_expire_at + INSERT INTO enc_users_settings ( + key_hash, + enc_odoo_url, + enc_odoo_token, + enc_login_token, + enc_login_token_expire_at, + enc_translations, + enc_translations_expire_at ) VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (email) DO UPDATE - SET odoo_url = EXCLUDED.odoo_url, - odoo_token = EXCLUDED.odoo_token, - login_token = EXCLUDED.login_token, - login_token_expire_at = EXCLUDED.login_token_expire_at, - translations = EXCLUDED.translations, - translations_expire_at = EXCLUDED.translations_expire_at + ON CONFLICT (key_hash) DO UPDATE + SET + enc_odoo_url = EXCLUDED.enc_odoo_url, + enc_odoo_token = EXCLUDED.enc_odoo_token, + enc_login_token = EXCLUDED.enc_login_token, + enc_login_token_expire_at = EXCLUDED.enc_login_token_expire_at, + enc_translations = EXCLUDED.enc_translations, + enc_translations_expire_at = EXCLUDED.enc_translations_expire_at `, [ - this.email, - this.odooUrl, - this.odooToken, - this.loginToken, - this.loginTokenExpireAt, - this.translations, - this.translationsExpireAt, + hmacSha256(this.encryptionKey, "Hash Key Salt"), + enc(this.odooUrl), + enc(this.odooToken), + enc(this.loginToken), + enc(this.loginTokenExpireAt?.toISOString()), + enc(JSON.stringify(this.translations)), + enc(this.translationsExpireAt?.toISOString()), ], ); } @@ -88,7 +98,6 @@ export class User { * Check the token we receive from Google, and get the user based on the email. */ static async getUserFromGoogleToken(event: any): Promise { - const oAuth2Client = new OAuth2Client(); const decodedToken = await oAuth2Client.verifyIdToken({ idToken: event.authorizationEventObject.userIdToken, audience: CLIENT_ID, @@ -97,7 +106,12 @@ export class User { if (!payload.email || !payload.email_verified) { throw new Error("Failed to authenticate the user"); } - return await User._getUserFromEmail(payload.email); + + const encryptionKey = await deriveKeyScryptCached( + `${payload.sub}-${payload.email.toLowerCase()}`, + "Odoo-Gmail-Addin-Salt", + ); + return await User._getUserFromEncryptionKey(encryptionKey, payload.email); } /** @@ -105,8 +119,12 @@ export class User { * * The login token can only be used once, and is reset after getting the user. */ - static async getUserFromLoginToken(email: string, loginToken: string): Promise { - const user = await User._getUserFromEmail(email); + static async getUserFromLoginToken( + email: string, + encryptionKey: Buffer, + loginToken: string, + ): Promise { + const user = await User._getUserFromEncryptionKey(encryptionKey, email); // constant time comparison if (!loginToken?.length || loginToken?.length !== user.loginToken?.length) { @@ -140,36 +158,47 @@ export class User { await this.save(); } - private static async _getUserFromEmail(email: string): Promise { + private static async _getUserFromEncryptionKey( + encryptionKey: Buffer, + email: string, + ): Promise { + const keyHash = hmacSha256(encryptionKey, "Hash Key Salt"); const result = await pool.query( ` SELECT id, - email, - odoo_url, - odoo_token, - login_token, - login_token_expire_at, - translations, - translations_expire_at - FROM users_settings - WHERE email = $1 + enc_odoo_url, + enc_odoo_token, + enc_login_token, + enc_login_token_expire_at, + enc_translations, + enc_translations_expire_at + FROM enc_users_settings + WHERE key_hash = $1 `, - [email], + [keyHash], ); if (result.rows.length === 0) { - return new User(null, email); + // new user + return new User(null, encryptionKey, email); } - const data = result.rows[0]; - return new User( - data.id, - email, - data.odoo_url, - data.odoo_token, - data.login_token, - data.login_token_expire_at, - data.translations, - data.translations_expire_at, - ); + try { + const dec = (ct) => decryptAesGcm(ct, encryptionKey); + const data = result.rows[0]; + return new User( + data.id, + encryptionKey, + email, + dec(data.enc_odoo_url), + dec(data.enc_odoo_token), + dec(data.enc_login_token), + data.enc_login_token_expire_at && new Date(dec(data.enc_login_token_expire_at)), + data.enc_translations && JSON.parse(dec(data.enc_translations)), + data.enc_translations_expire_at && new Date(dec(data.enc_translations_expire_at)), + ); + } catch (error) { + console.log(`Decryption failed for ${email}, ${error}`); + return new User(null, encryptionKey, email); + } } } diff --git a/gmail/src/services/odoo_auth.ts b/gmail/src/services/odoo_auth.ts index 9ab0b54f3..3688c98bd 100644 --- a/gmail/src/services/odoo_auth.ts +++ b/gmail/src/services/odoo_auth.ts @@ -1,5 +1,6 @@ import { HOST, ODOO_AUTH_URLS } from "../consts"; import { User } from "../models/user"; +import { decryptAesGcm, encryptAesGcm, getApplicationKey } from "../utils/encrypt"; import { encodeQueryData, postJsonRpc } from "../utils/http"; import { ERROR_PAGE, RAINBOW } from "./pages"; @@ -14,15 +15,23 @@ import { ERROR_PAGE, RAINBOW } from "./pages"; * 5. The auth code is exchanged for an access token with a RPC call */ export async function odooAuthCallback(callbackRequest: any) { - const { success, auth_code: authCode, state } = callbackRequest.query; + const { success, auth_code: authCode, state: encStateHex } = callbackRequest.query; if (success !== "1") { return ERROR_PAGE.replace("__ERROR_MESSAGE__", "Odoo did not return successfully."); } - const { email, loginToken } = JSON.parse(state); + const appKey = await getApplicationKey(); + let response = null; let user = null; try { - user = await User.getUserFromLoginToken(email, loginToken); + const encState = Buffer.from(encStateHex, "hex"); + const { + email, + loginToken, + encryptionKey: encryptionKeyHex, + } = JSON.parse(decryptAesGcm(encState, appKey)); + const encryptionKey = Buffer.from(encryptionKeyHex, "hex"); + user = await User.getUserFromLoginToken(email, encryptionKey, loginToken); console.log("Get access token from auth code..."); response = await postJsonRpc(user.odooUrl + ODOO_AUTH_URLS.CODE_VALIDATION, { @@ -31,7 +40,8 @@ export async function odooAuthCallback(callbackRequest: any) { if (!response || !response.access_token || !response.access_token.length) { throw new Error("Odoo exchange failed"); } - } catch { + } catch (e) { + console.error(`Error during authentication process: ${e}`); return ERROR_PAGE.replace( "__ERROR_MESSAGE__", "The token exchange failed. Maybe your token has expired or your database can not be reached by the Google server." + @@ -55,17 +65,21 @@ export async function getOdooAuthUrl(user: User): Promise { throw new Error("Can not retrieve the Odoo database URL."); } - const loginToken = await user.generateLoginToken(); - - const redirectToAddon = `${HOST}/auth_callback`; + const state = { + loginToken: await user.generateLoginToken(), + email: user.email, + encryptionKey: user.encryptionKey.toString("hex"), + }; + const appKey = await getApplicationKey(); + const encState = encryptAesGcm(JSON.stringify(state), appKey); return ( odooUrl + ODOO_AUTH_URLS.AUTH_CODE + encodeQueryData({ - redirect: redirectToAddon, + redirect: `${HOST}/auth_callback`, friendlyname: "Gmail", - state: JSON.stringify({ loginToken, email: user.email }), + state: encState.toString("hex"), scope: ODOO_AUTH_URLS.SCOPE, }) ); diff --git a/gmail/src/utils/encrypt.ts b/gmail/src/utils/encrypt.ts new file mode 100644 index 000000000..e4bde8651 --- /dev/null +++ b/gmail/src/utils/encrypt.ts @@ -0,0 +1,106 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, + scrypt, +} from "node:crypto"; +import { promisify } from "node:util"; + +/** + * Encrypt in AES 256 GCM the given plaintext. + */ +export function encryptAesGcm( + plaintext: string | Buffer | undefined, + key: Buffer, + iv?: Buffer, +): Buffer | undefined { + if (!plaintext) { + // for simplicity, don't encrypt undefined value + return; + } + if (key.length !== 32) { + throw new TypeError("Invalid key"); + } + + iv = iv || randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 }); + const ciphertext = cipher.update(plaintext); + cipher.final(); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, ciphertext]); +} + +/** + * Decrypt in AES 256 GCM the given payload. + */ +export function decryptAesGcm(ivTagCt: Buffer | undefined, key: Buffer): string | undefined { + if (!ivTagCt) { + // for simplicity, don't encrypt undefined value + return; + } + if (ivTagCt.length < 12 + 16) { + throw new Error("Invalid ciphertext"); + } + const iv = ivTagCt.subarray(0, 12); + const tag = ivTagCt.subarray(12, 12 + 16); + const ciphertext = ivTagCt.subarray(12 + 16); + const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 }); + decipher.setAuthTag(tag); + const plaintext = decipher.update(ciphertext); + decipher.final(); // check tag + return plaintext.toString(); +} + +const _scryptAsync = promisify(scrypt) as ( + password: string, + salt: string, + keylen: number, +) => Promise; + +/** + * Derive a 256 bits key from the given seed with scrypt. + */ +export async function deriveKeyScrypt(seed: string, salt: string): Promise { + if (!seed.length || !salt.length) { + throw new TypeError("Invalid seed"); + } + return _scryptAsync(seed, salt, 32); +} + +const KEY_CACHE_MAX_SIZE = 10000; +const _derivedKeyCache = new Map(); + +export async function deriveKeyScryptCached(seed: string, salt: string): Promise { + const cacheKey = createHash("sha256").update(`${salt}-${seed}`).digest("hex"); + const cached = _derivedKeyCache.get(cacheKey); + if (cached) { + // refresh LRU order + _derivedKeyCache.delete(cacheKey); + _derivedKeyCache.set(cacheKey, cached); + return cached; + } + + const key = await deriveKeyScrypt(seed, salt); + if (_derivedKeyCache.size >= KEY_CACHE_MAX_SIZE) { + // remove the oldest entry + _derivedKeyCache.delete(_derivedKeyCache.keys().next().value); + } + _derivedKeyCache.set(cacheKey, key); + return key; +} + +export function hmacSha256(key: Buffer | string, data: string): Buffer { + return createHmac("sha256", key).update(data).digest(); +} + +/** + * Derive the application key from the application secret. + */ +export async function getApplicationKey(): Promise { + if (!process.env.APP_SECRET?.length) { + throw new Error("Application secret not configured"); + } + return deriveKeyScryptCached(process.env.APP_SECRET, "APPLICATION KEY"); +} diff --git a/gmail/src/views/debug.ts b/gmail/src/views/debug.ts index 7a96fd84b..be116e83f 100644 --- a/gmail/src/views/debug.ts +++ b/gmail/src/views/debug.ts @@ -26,7 +26,6 @@ export function onOpenDebugView(state: State, _t: Function, user: User): EventRe new TextParagraph(_t("Debug zone for development purpose.")), new DecoratedText(_t("Odoo Server URL"), user.odooUrl), new DecoratedText(_t("Odoo Access Token"), user.odooToken), - new DecoratedText(_t("Odoo Access Token"), user.odooToken), new Button(_t("Clear Translations Cache"), new ActionCall(state, onClearTranslationCache)), ]); const card = new Card([section]); diff --git a/gmail/tests/encrypt.test.ts b/gmail/tests/encrypt.test.ts new file mode 100644 index 000000000..4dc582bcf --- /dev/null +++ b/gmail/tests/encrypt.test.ts @@ -0,0 +1,145 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { + encryptAesGcm, + decryptAesGcm, + deriveKeyScrypt, + deriveKeyScryptCached, +} from "../src/utils/encrypt"; + +function randomString(n: number): string { + return randomBytes(n).toString("base64url").slice(0, n); +} + +test("decrypt(encrypt(x))", () => { + assert.deepEqual(encryptAesGcm(undefined, randomBytes(32)), undefined); + for (let size = 1; size < 1000; ++size) { + const key = randomBytes(32); + const original = randomString(size); + const encrypted = encryptAesGcm(original, key); + assert.equal(encrypted?.length, 12 + 16 + size); // GCM is stream cipher + const decrypted = decryptAesGcm(encrypted, key); + assert.equal(decrypted, original); + assert.ok(Buffer.isBuffer(encrypted)); + } +}); + +test("check that the IV change", () => { + const key = randomBytes(32); + const first = encryptAesGcm("plaintext", key); + const second = encryptAesGcm("plaintext", key); + assert.notDeepEqual(first, second); +}); + +test("wrong key raises", () => { + const goodKey = randomBytes(32); + const badKey = randomBytes(32); + const encrypted = encryptAesGcm("plaintext", goodKey); + assert.equal(decryptAesGcm(encrypted, goodKey), "plaintext"); + assert.throws(() => decryptAesGcm(encrypted, badKey)); +}); + +test("raise when altering iv - tag - ct", () => { + const key = randomBytes(32); + const encrypted = encryptAesGcm("plaintext plaintext plaintext", key); + assert.ok(encrypted); + + // [IV: 12][tag: 16][ciphertext] + const modifiedIv = Buffer.from(encrypted); + modifiedIv[3] ^= 0xff; + assert.throws(() => decryptAesGcm(modifiedIv, key)); + + const modifiedTag = Buffer.from(encrypted); + modifiedTag[17] ^= 0xff; + assert.throws(() => decryptAesGcm(modifiedTag, key)); + + // pt block 1 + const modifiedCt1 = Buffer.from(encrypted); + modifiedCt1[40] ^= 0xff; + assert.throws(() => decryptAesGcm(modifiedCt1, key)); + + // pt block 2 + const modifiedCt2 = Buffer.from(encrypted); + modifiedCt2[55] ^= 0xff; + assert.throws(() => decryptAesGcm(modifiedCt2, key)); +}); + +test("raise when wrong size", () => { + const key = randomBytes(32); + const encrypted = encryptAesGcm("plaintext", key); + assert.ok(encrypted); + assert.throws(() => decryptAesGcm(encrypted.subarray(0, 0), key)); + assert.throws(() => decryptAesGcm(encrypted.subarray(0, 2), key)); +}); + +test("derives the same password with the same salt", async () => { + const startedAt = performance.now(); + const key1 = await deriveKeyScrypt("password", "salt"); + const durationMs = performance.now() - startedAt; + console.log(`scrypt took ${durationMs.toFixed(2)} ms`); // ~50ms on laptop + const key2 = await deriveKeyScrypt("password", "salt"); + assert.equal(key1.length, 32); + assert.deepEqual(key1, key2); +}); + +test("derives different password with the same salt", async () => { + const key1 = await deriveKeyScrypt("password 1", "salt"); + const key2 = await deriveKeyScrypt("password 2", "salt"); + assert.equal(key1.length, 32); + assert.equal(key2.length, 32); + assert.notDeepEqual(key1, key2); +}); + + +test("derives with no salt raises", async () => { + await assert.rejects(() => deriveKeyScrypt("password 2", "")); +}); + + +test("deriveKeyScryptCached", async () => { + const password = randomString(64); + // first generation is slow + const startedAt1 = performance.now(); + const key1 = await deriveKeyScryptCached(password, "salt"); + const time1 = performance.now() - startedAt1; + + // second hit the cache + const startedAt2 = performance.now(); + const key2 = await deriveKeyScryptCached(password, "salt"); + const time2 = performance.now() - startedAt2; + + assert.deepEqual(key1, key2); + assert.ok(time1 > time2); + assert.ok(time1 > time2 + 10); +}); + + +/* + * Test vector from python. + +# python -m pip install pycryptodome +from Crypto.Cipher import AES +from Crypto.Protocol.KDF import scrypt + +def encrypt(iv: bytes, plaintext: str, password: str, salt: str) -> bytes: + key = scrypt(password=password, salt=salt, key_len=32, N=2**14, r=8, p=1) + cipher = AES.new(key, AES.MODE_GCM, nonce=iv, mac_len=16) + ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode()) + return iv + tag + ciphertext + +ct = encrypt(b"123456789abc", "plaintext", "password", "scrypt salt") +# 3132333435363738396162639c93d3196295b1820830472ef5d528cc54caa4a0acc8b9ce4b +print(ct.hex()) + */ +test("matches the Python test vector", async () => { + const iv = Buffer.from("123456789abc"); + + const key = await deriveKeyScrypt("password", "scrypt salt"); + const encrypted = encryptAesGcm("plaintext", key, iv); + assert.ok(encrypted); + + const expected = "3132333435363738396162639c93d3196295b1820830472ef5d528cc54caa4a0acc8b9ce4b" + assert.equal(encrypted.toString("hex"), expected); +});