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); +});