From bbc6ce8592fdfe00a53eeec3d7af0299b8c7aa2e Mon Sep 17 00:00:00 2001 From: Maxime Cannoodt Date: Thu, 25 Aug 2022 00:13:44 +0200 Subject: [PATCH 1/2] feat: :sparkles: migration to WebCrypto API --- plugin | 2 +- .../migration.sql | 15 +++++ server/prisma/schema.prisma | 11 ++-- .../controllers/note/note.post.controller.ts | 4 ++ .../note/note.post.controller.unit.test.ts | 38 ++++++++++- webapp/src/lib/crypto/decrypt.test.ts | 50 +++++++++----- webapp/src/lib/crypto/decrypt.ts | 65 ++++++++++++++++++- webapp/src/lib/model/EncryptedNote.ts | 1 + webapp/src/routes/note/[id].svelte | 5 +- webapp/src/types/index.d.ts | 7 ++ webapp/tsconfig.json | 3 +- 11 files changed, 170 insertions(+), 31 deletions(-) create mode 100644 server/prisma/migrations/20220825074533_add_crypto_version/migration.sql create mode 100644 webapp/src/types/index.d.ts diff --git a/plugin b/plugin index e207051..8b285ce 160000 --- a/plugin +++ b/plugin @@ -1 +1 @@ -Subproject commit e207051e611f271944124192a8fd6e217f2fd419 +Subproject commit 8b285ce3f4f6d7a519fcd0cc169e2a32b59f87eb diff --git a/server/prisma/migrations/20220825074533_add_crypto_version/migration.sql b/server/prisma/migrations/20220825074533_add_crypto_version/migration.sql new file mode 100644 index 0000000..50c45e7 --- /dev/null +++ b/server/prisma/migrations/20220825074533_add_crypto_version/migration.sql @@ -0,0 +1,15 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_EncryptedNote" ( + "id" TEXT NOT NULL PRIMARY KEY, + "insert_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expire_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ciphertext" TEXT NOT NULL, + "hmac" TEXT NOT NULL, + "crypto_version" TEXT NOT NULL DEFAULT 'v1' +); +INSERT INTO "new_EncryptedNote" ("ciphertext", "expire_time", "hmac", "id", "insert_time") SELECT "ciphertext", "expire_time", "hmac", "id", "insert_time" FROM "EncryptedNote"; +DROP TABLE "EncryptedNote"; +ALTER TABLE "new_EncryptedNote" RENAME TO "EncryptedNote"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 6d38662..7c217e0 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -11,11 +11,12 @@ datasource db { } model EncryptedNote { - id String @id @default(cuid()) - insert_time DateTime @default(now()) - expire_time DateTime @default(now()) - ciphertext String - hmac String + id String @id @default(cuid()) + insert_time DateTime @default(now()) + expire_time DateTime @default(now()) + ciphertext String + hmac String + crypto_version String @default("v1") } model event { diff --git a/server/src/controllers/note/note.post.controller.ts b/server/src/controllers/note/note.post.controller.ts index c739d24..6defe41 100644 --- a/server/src/controllers/note/note.post.controller.ts +++ b/server/src/controllers/note/note.post.controller.ts @@ -33,6 +33,9 @@ export class NotePostRequest { @ValidateIf((o) => o.plugin_version != null) @Matches("^[0-9]+\\.[0-9]+\\.[0-9]+$") plugin_version: string | undefined; + + @Matches("^v[0-9]+$") + crypto_version: string = "v1"; } export async function postNoteController( @@ -75,6 +78,7 @@ export async function postNoteController( ciphertext: notePostRequest.ciphertext as string, hmac: notePostRequest.hmac as string, expire_time: addDays(new Date(), EXPIRE_WINDOW_DAYS), + crypto_version: notePostRequest.crypto_version, } as EncryptedNote; // Store note object diff --git a/server/src/controllers/note/note.post.controller.unit.test.ts b/server/src/controllers/note/note.post.controller.unit.test.ts index 2c23ca6..c15af2f 100644 --- a/server/src/controllers/note/note.post.controller.unit.test.ts +++ b/server/src/controllers/note/note.post.controller.unit.test.ts @@ -15,6 +15,8 @@ const MALFORMED_VERSION = "v1.0.0"; const VALID_USER_ID = "f06536e7df6857fc"; const MALFORMED_ID_WRONG_CRC = "f06536e7df6857fd"; const MALFORMED_ID_WRONG_LENGTH = "0"; +const VALID_CRYPTO_VERSION = "v99"; +const MALFORMED_CRYPTO_VERSION = "32"; const MOCK_NOTE_ID = "1234"; @@ -36,7 +38,6 @@ const TEST_PAYLOADS: TestParams[] = [ { payload: { ciphertext: VALID_CIPHERTEXT, - hmac: VALID_HMAC, user_id: VALID_USER_ID, plugin_version: VALID_VERSION, @@ -120,6 +121,28 @@ const TEST_PAYLOADS: TestParams[] = [ }, expectedStatus: 400, }, + // Request with valid ciphertext, hmac, user id, plugin version, and crypto version + { + payload: { + ciphertext: VALID_CIPHERTEXT, + hmac: VALID_HMAC, + user_id: VALID_USER_ID, + plugin_version: VALID_VERSION, + crypto_version: VALID_CRYPTO_VERSION, + }, + expectedStatus: 200, + }, + // Request with malformed crypto version + { + payload: { + ciphertext: VALID_CIPHERTEXT, + hmac: VALID_HMAC, + user_id: VALID_USER_ID, + plugin_version: VALID_VERSION, + crypto_version: MALFORMED_CRYPTO_VERSION, + }, + expectedStatus: 400, + }, ]; describe("note.post.controller", () => { @@ -161,6 +184,19 @@ describe("note.post.controller", () => { ); } + // Validate DAO calls + if (expectedStatus === 200) { + expect(mockNoteDao.createNote).toHaveBeenCalledTimes(1); + expect(mockNoteDao.createNote).toHaveBeenCalledWith( + expect.objectContaining({ + ciphertext: payload.ciphertext, + hmac: payload.hmac, + crypto_version: payload.crypto_version || "v1", + expire_time: expect.any(Date), + }) + ); + } + // Validate Write events expect(mockEventLogger.writeEvent).toHaveBeenCalledOnce(); if (expectedStatus === 200) { diff --git a/webapp/src/lib/crypto/decrypt.test.ts b/webapp/src/lib/crypto/decrypt.test.ts index 402a525..9130711 100644 --- a/webapp/src/lib/crypto/decrypt.test.ts +++ b/webapp/src/lib/crypto/decrypt.test.ts @@ -1,25 +1,39 @@ -import { expect, it } from 'vitest'; -import decrypt from './decrypt'; +import { expect, describe, it, vi } from 'vitest'; +import { webcrypto } from 'crypto'; +import { decrypt_v1, decrypt_v2 } from './decrypt'; -const TEST_NOTE = { +vi.stubGlobal('crypto', { + subtle: webcrypto.subtle +}); + +const TEST_NOTE_V1 = { ciphertext: 'U2FsdGVkX1+r+nJffb6piMq1hPFSBSkf9/sgXj/UalA=', hmac: '7bfd5b0e96a0ed7ea43091d3e26f7c487bcebf8ba06175a4d4fc4d8466ba37f6' }; -const TEST_KEY = 'mgyUwoFwhlb1cnjhYYSrkY9_7hZKcRHQJs5l8wYB3Vk'; -const TEST_PLAINTEXT = 'You did it!'; +const TEST_KEY_V1 = 'mgyUwoFwhlb1cnjhYYSrkY9_7hZKcRHQJs5l8wYB3Vk'; +const TEST_PLAINTEXT_V1 = 'You did it!'; -it('Should return plaintext with the correct key', () => { - decrypt({ ...TEST_NOTE, key: TEST_KEY }).then((plaintext) => { - expect(plaintext).toContain(TEST_PLAINTEXT); +const TEST_NOTE_V2 = { + ciphertext: '7u2HlkxEfptYF0KTIkSLHBbNumP58XjfjEuLb2qG0tw=', + hmac: '6SDEr9vCn4qM0u6+yFt/e+8Z1LLCNcCTw4GB4aNVMXM=' +}; +const TEST_KEY_V2 = 'fzrpzrhjyeBgZNJTlIQ5GmduQ+AywMUFPY9ZisP6A9c='; +const TEST_PLAINTEXT_V2 = 'This is the test data.'; + +describe.each([ + { decrypt_func: decrypt_v1, note: TEST_NOTE_V1, key: TEST_KEY_V1, plaintext: TEST_PLAINTEXT_V1 }, + { decrypt_func: decrypt_v2, note: TEST_NOTE_V2, key: TEST_KEY_V2, plaintext: TEST_PLAINTEXT_V2 } +])('decrypt', ({ decrypt_func, note, key, plaintext }) => { + it('Should return plaintext with the correct key', async () => { + const test_plaintext = await decrypt_func({ ...note, key: key }); + expect(test_plaintext).toContain(plaintext); + }); + + it('Should throw with the wrong key', async () => { + await expect(decrypt_v1({ ...note, key: '' })).rejects.toThrow('Failed HMAC check'); + }); + + it('Should throw with the wrong HMAC', async () => { + await expect(decrypt_v1({ ...note, hmac: '', key: key })).rejects.toThrow('Failed HMAC check'); }); }); - -it('Should throw with the wrong key', async () => { - await expect(decrypt({ ...TEST_NOTE, key: '' })).rejects.toThrow('Failed HMAC check'); -}); - -it('Should throw with the wrong HMAC', async () => { - await expect(decrypt({ ...TEST_NOTE, hmac: '', key: TEST_KEY })).rejects.toThrow( - 'Failed HMAC check' - ); -}); diff --git a/webapp/src/lib/crypto/decrypt.ts b/webapp/src/lib/crypto/decrypt.ts index aa0077a..d5cc472 100644 --- a/webapp/src/lib/crypto/decrypt.ts +++ b/webapp/src/lib/crypto/decrypt.ts @@ -1,7 +1,22 @@ +// TODO: should be same source code as used in the plugin!! + import { AES, enc, HmacSHA256 } from 'crypto-js'; -// TODO: should be same source code as used in the plugin!! -export default async function decrypt(cryptData: { +export async function decrypt( + cryptData: { ciphertext: string; hmac: string; key: string }, + version: string +): Promise { + console.debug(`decrypting with crypto suite ${version}`); + if (version === 'v1') { + return decrypt_v1(cryptData); + } + if (version === 'v2') { + return decrypt_v2(cryptData); + } + throw new Error(`Unsupported crypto version: ${version}`); +} + +export async function decrypt_v1(cryptData: { ciphertext: string; hmac: string; key: string; @@ -15,3 +30,49 @@ export default async function decrypt(cryptData: { const md = AES.decrypt(cryptData.ciphertext, cryptData.key).toString(enc.Utf8); return md; } + +export async function decrypt_v2(cryptData: { + ciphertext: string; + hmac: string; + key: string; +}): Promise { + const secret = base64ToArrayBuffer(cryptData.key); + const ciphertext_buf = base64ToArrayBuffer(cryptData.ciphertext); + const hmac_buf = base64ToArrayBuffer(cryptData.hmac); + + const is_authentic = await window.crypto.subtle.verify( + { name: 'HMAC', hash: 'SHA-256' }, + await _getSignKey(secret), + hmac_buf, + ciphertext_buf + ); + + if (!is_authentic) { + throw Error('Failed HMAC check'); + } + + const md = await window.crypto.subtle.decrypt( + { name: 'AES-CBC', iv: new Uint8Array(16) }, + await _getAesKey(secret), + ciphertext_buf + ); + return new TextDecoder().decode(md); +} + +function _getAesKey(secret: ArrayBuffer): Promise { + return window.crypto.subtle.importKey('raw', secret, { name: 'AES-CBC', length: 256 }, false, [ + 'encrypt', + 'decrypt' + ]); +} + +function _getSignKey(secret: ArrayBuffer): Promise { + return window.crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-256' }, false, [ + 'sign', + 'verify' + ]); +} + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + return Uint8Array.from(window.atob(base64), (c) => c.charCodeAt(0)); +} diff --git a/webapp/src/lib/model/EncryptedNote.ts b/webapp/src/lib/model/EncryptedNote.ts index d2748db..3da736e 100644 --- a/webapp/src/lib/model/EncryptedNote.ts +++ b/webapp/src/lib/model/EncryptedNote.ts @@ -4,4 +4,5 @@ export type EncryptedNote = { expire_time: Date; ciphertext: string; hmac: string; + crypto_version: string; }; diff --git a/webapp/src/routes/note/[id].svelte b/webapp/src/routes/note/[id].svelte index 25dd913..7f86569 100644 --- a/webapp/src/routes/note/[id].svelte +++ b/webapp/src/routes/note/[id].svelte @@ -17,7 +17,7 @@