Merge pull request #27 from mcndt/migration-subtlecrypto

Migration subtlecrypto
This commit is contained in:
Maxime Cannoodt 2022-08-25 10:22:59 +02:00 committed by GitHub
commit f84ddba528
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 170 additions and 31 deletions

2
plugin

@ -1 +1 @@
Subproject commit e207051e611f271944124192a8fd6e217f2fd419
Subproject commit a6d4bc71fb93049516ee107c31c3bde5aa26e597

View File

@ -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;

View File

@ -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 {

View File

@ -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

View File

@ -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) {

View File

@ -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'
);
});

View File

@ -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<string> {
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<string> {
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<CryptoKey> {
return window.crypto.subtle.importKey('raw', secret, { name: 'AES-CBC', length: 256 }, false, [
'encrypt',
'decrypt'
]);
}
function _getSignKey(secret: ArrayBuffer): Promise<CryptoKey> {
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));
}

View File

@ -4,4 +4,5 @@ export type EncryptedNote = {
expire_time: Date;
ciphertext: string;
hmac: string;
crypto_version: string;
};

View File

@ -17,7 +17,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import decrypt from '$lib/crypto/decrypt';
import { decrypt } from '$lib/crypto/decrypt';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
import LogoMarkdown from 'svelte-icons/io/IoLogoMarkdown.svelte';
import IconEncrypted from 'svelte-icons/md/MdLockOutline.svelte';
@ -35,9 +35,8 @@
onMount(() => {
if (browser) {
// Decrypt note
const key = location.hash.slice(1);
decrypt({ ...note, key })
decrypt({ ...note, key }, note.crypto_version)
.then((value) => (plaintext = value))
.catch(() => (decryptFailed = true));
}

7
webapp/src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
// Solution for adding crypto definitions: https://stackoverflow.com/questions/71525466/property-subtle-does-not-exist-on-type-typeof-webcrypto
declare module "crypto" {
namespace webcrypto {
const subtle: SubtleCrypto;
}
}

View File

@ -9,7 +9,8 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
"types": ["vitest/globals", "@testing-library/jest-dom"],
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"paths": {
"$lib": ["src/lib"],