Merge pull request #27 from mcndt/migration-subtlecrypto
Migration subtlecrypto
This commit is contained in:
commit
f84ddba528
2
plugin
2
plugin
@ -1 +1 @@
|
|||||||
Subproject commit e207051e611f271944124192a8fd6e217f2fd419
|
Subproject commit a6d4bc71fb93049516ee107c31c3bde5aa26e597
|
@ -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;
|
@ -16,6 +16,7 @@ model EncryptedNote {
|
|||||||
expire_time DateTime @default(now())
|
expire_time DateTime @default(now())
|
||||||
ciphertext String
|
ciphertext String
|
||||||
hmac String
|
hmac String
|
||||||
|
crypto_version String @default("v1")
|
||||||
}
|
}
|
||||||
|
|
||||||
model event {
|
model event {
|
||||||
|
@ -33,6 +33,9 @@ export class NotePostRequest {
|
|||||||
@ValidateIf((o) => o.plugin_version != null)
|
@ValidateIf((o) => o.plugin_version != null)
|
||||||
@Matches("^[0-9]+\\.[0-9]+\\.[0-9]+$")
|
@Matches("^[0-9]+\\.[0-9]+\\.[0-9]+$")
|
||||||
plugin_version: string | undefined;
|
plugin_version: string | undefined;
|
||||||
|
|
||||||
|
@Matches("^v[0-9]+$")
|
||||||
|
crypto_version: string = "v1";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postNoteController(
|
export async function postNoteController(
|
||||||
@ -75,6 +78,7 @@ export async function postNoteController(
|
|||||||
ciphertext: notePostRequest.ciphertext as string,
|
ciphertext: notePostRequest.ciphertext as string,
|
||||||
hmac: notePostRequest.hmac as string,
|
hmac: notePostRequest.hmac as string,
|
||||||
expire_time: addDays(new Date(), EXPIRE_WINDOW_DAYS),
|
expire_time: addDays(new Date(), EXPIRE_WINDOW_DAYS),
|
||||||
|
crypto_version: notePostRequest.crypto_version,
|
||||||
} as EncryptedNote;
|
} as EncryptedNote;
|
||||||
|
|
||||||
// Store note object
|
// Store note object
|
||||||
|
@ -15,6 +15,8 @@ const MALFORMED_VERSION = "v1.0.0";
|
|||||||
const VALID_USER_ID = "f06536e7df6857fc";
|
const VALID_USER_ID = "f06536e7df6857fc";
|
||||||
const MALFORMED_ID_WRONG_CRC = "f06536e7df6857fd";
|
const MALFORMED_ID_WRONG_CRC = "f06536e7df6857fd";
|
||||||
const MALFORMED_ID_WRONG_LENGTH = "0";
|
const MALFORMED_ID_WRONG_LENGTH = "0";
|
||||||
|
const VALID_CRYPTO_VERSION = "v99";
|
||||||
|
const MALFORMED_CRYPTO_VERSION = "32";
|
||||||
|
|
||||||
const MOCK_NOTE_ID = "1234";
|
const MOCK_NOTE_ID = "1234";
|
||||||
|
|
||||||
@ -36,7 +38,6 @@ const TEST_PAYLOADS: TestParams[] = [
|
|||||||
{
|
{
|
||||||
payload: {
|
payload: {
|
||||||
ciphertext: VALID_CIPHERTEXT,
|
ciphertext: VALID_CIPHERTEXT,
|
||||||
|
|
||||||
hmac: VALID_HMAC,
|
hmac: VALID_HMAC,
|
||||||
user_id: VALID_USER_ID,
|
user_id: VALID_USER_ID,
|
||||||
plugin_version: VALID_VERSION,
|
plugin_version: VALID_VERSION,
|
||||||
@ -120,6 +121,28 @@ const TEST_PAYLOADS: TestParams[] = [
|
|||||||
},
|
},
|
||||||
expectedStatus: 400,
|
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", () => {
|
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
|
// Validate Write events
|
||||||
expect(mockEventLogger.writeEvent).toHaveBeenCalledOnce();
|
expect(mockEventLogger.writeEvent).toHaveBeenCalledOnce();
|
||||||
if (expectedStatus === 200) {
|
if (expectedStatus === 200) {
|
||||||
|
@ -1,25 +1,39 @@
|
|||||||
import { expect, it } from 'vitest';
|
import { expect, describe, it, vi } from 'vitest';
|
||||||
import decrypt from './decrypt';
|
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=',
|
ciphertext: 'U2FsdGVkX1+r+nJffb6piMq1hPFSBSkf9/sgXj/UalA=',
|
||||||
hmac: '7bfd5b0e96a0ed7ea43091d3e26f7c487bcebf8ba06175a4d4fc4d8466ba37f6'
|
hmac: '7bfd5b0e96a0ed7ea43091d3e26f7c487bcebf8ba06175a4d4fc4d8466ba37f6'
|
||||||
};
|
};
|
||||||
const TEST_KEY = 'mgyUwoFwhlb1cnjhYYSrkY9_7hZKcRHQJs5l8wYB3Vk';
|
const TEST_KEY_V1 = 'mgyUwoFwhlb1cnjhYYSrkY9_7hZKcRHQJs5l8wYB3Vk';
|
||||||
const TEST_PLAINTEXT = 'You did it!';
|
const TEST_PLAINTEXT_V1 = 'You did it!';
|
||||||
|
|
||||||
it('Should return plaintext with the correct key', () => {
|
const TEST_NOTE_V2 = {
|
||||||
decrypt({ ...TEST_NOTE, key: TEST_KEY }).then((plaintext) => {
|
ciphertext: '7u2HlkxEfptYF0KTIkSLHBbNumP58XjfjEuLb2qG0tw=',
|
||||||
expect(plaintext).toContain(TEST_PLAINTEXT);
|
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 () => {
|
it('Should throw with the wrong key', async () => {
|
||||||
await expect(decrypt({ ...TEST_NOTE, key: '' })).rejects.toThrow('Failed HMAC check');
|
await expect(decrypt_v1({ ...note, key: '' })).rejects.toThrow('Failed HMAC check');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should throw with the wrong HMAC', async () => {
|
it('Should throw with the wrong HMAC', async () => {
|
||||||
await expect(decrypt({ ...TEST_NOTE, hmac: '', key: TEST_KEY })).rejects.toThrow(
|
await expect(decrypt_v1({ ...note, hmac: '', key: key })).rejects.toThrow('Failed HMAC check');
|
||||||
'Failed HMAC check'
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,22 @@
|
|||||||
|
// TODO: should be same source code as used in the plugin!!
|
||||||
|
|
||||||
import { AES, enc, HmacSHA256 } from 'crypto-js';
|
import { AES, enc, HmacSHA256 } from 'crypto-js';
|
||||||
|
|
||||||
// TODO: should be same source code as used in the plugin!!
|
export async function decrypt(
|
||||||
export default async function decrypt(cryptData: {
|
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;
|
ciphertext: string;
|
||||||
hmac: string;
|
hmac: string;
|
||||||
key: string;
|
key: string;
|
||||||
@ -15,3 +30,49 @@ export default async function decrypt(cryptData: {
|
|||||||
const md = AES.decrypt(cryptData.ciphertext, cryptData.key).toString(enc.Utf8);
|
const md = AES.decrypt(cryptData.ciphertext, cryptData.key).toString(enc.Utf8);
|
||||||
return md;
|
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));
|
||||||
|
}
|
||||||
|
@ -4,4 +4,5 @@ export type EncryptedNote = {
|
|||||||
expire_time: Date;
|
expire_time: Date;
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
hmac: string;
|
hmac: string;
|
||||||
|
crypto_version: string;
|
||||||
};
|
};
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import decrypt from '$lib/crypto/decrypt';
|
import { decrypt } from '$lib/crypto/decrypt';
|
||||||
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
|
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
|
||||||
import LogoMarkdown from 'svelte-icons/io/IoLogoMarkdown.svelte';
|
import LogoMarkdown from 'svelte-icons/io/IoLogoMarkdown.svelte';
|
||||||
import IconEncrypted from 'svelte-icons/md/MdLockOutline.svelte';
|
import IconEncrypted from 'svelte-icons/md/MdLockOutline.svelte';
|
||||||
@ -35,9 +35,8 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// Decrypt note
|
|
||||||
const key = location.hash.slice(1);
|
const key = location.hash.slice(1);
|
||||||
decrypt({ ...note, key })
|
decrypt({ ...note, key }, note.crypto_version)
|
||||||
.then((value) => (plaintext = value))
|
.then((value) => (plaintext = value))
|
||||||
.catch(() => (decryptFailed = true));
|
.catch(() => (decryptFailed = true));
|
||||||
}
|
}
|
||||||
|
7
webapp/src/types/index.d.ts
vendored
Normal file
7
webapp/src/types/index.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -9,7 +9,8 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||||
|
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"$lib": ["src/lib"],
|
"$lib": ["src/lib"],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user