Skip to content

Commit

Permalink
Added password encryption
Browse files Browse the repository at this point in the history
  • Loading branch information
Yureien committed Jun 8, 2023
1 parent dd6bda9 commit c99aaa1
Show file tree
Hide file tree
Showing 11 changed files with 198 additions and 78 deletions.
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
DATABASE_URL="postgresql:https://johndoe:randompassword@localhost:5432/mydb?schema=public"
SALT="randomsalt"
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.2.4",
"@sveltejs/kit": "^1.5.0",
"@types/libsodium-wrappers": "^0.7.10",
"@types/prismjs": "^1.26.0",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.45.0",
Expand All @@ -39,7 +38,7 @@
"type": "module",
"dependencies": {
"@prisma/client": "^4.15.0",
"libsodium-wrappers": "^0.7.11",
"base64-js": "^1.5.1",
"prism-themes": "^1.9.0",
"prismjs": "^1.29.0",
"sanitize-html": "^2.10.0",
Expand Down
95 changes: 95 additions & 0 deletions src/lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import base64 from 'base64-js';

export async function encrypt(plaintext: string) {
const encoder = new TextEncoder();

const iv: Uint8Array = crypto.getRandomValues(new Uint8Array(12));
const ivStr = base64.fromByteArray(iv);

const alg = { name: 'AES-GCM', iv, length: 256 };

const key = (await crypto.subtle.generateKey(alg, true, ['encrypt'])) as CryptoKey;
const keyStr = base64.fromByteArray(new Uint8Array(await crypto.subtle.exportKey('raw', key)));
const enc = await crypto.subtle.encrypt(alg, key, encoder.encode(plaintext));
const encStr = base64.fromByteArray(new Uint8Array(enc));

return [encStr, `${ivStr};${keyStr}`];
}

export async function decrypt(ciphertext: string, ivKey: string) {
const decoder = new TextDecoder('utf-8');

const [ivStr, keyStr] = ivKey.split(';');
const iv = base64.toByteArray(ivStr);
const alg = { name: 'AES-GCM', iv, length: 256 };

const key = await crypto.subtle.importKey('raw', base64.toByteArray(keyStr), alg, false, [
'decrypt'
]);

const dec = await crypto.subtle.decrypt(alg, key, base64.toByteArray(ciphertext));
return decoder.decode(dec);
}

export async function encryptWithPassword(plaintext: string, password: string) {
const encoder = new TextEncoder();

const iv: Uint8Array = crypto.getRandomValues(new Uint8Array(12));
const ivStr = base64.fromByteArray(iv);
const alg = { name: 'AES-GCM', iv, length: 256 };

const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
const key = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: await crypto.subtle.digest('SHA-256', encoder.encode(password)),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
alg,
false,
['encrypt']
);
const enc = await crypto.subtle.encrypt(alg, key, encoder.encode(plaintext));
const encStr = base64.fromByteArray(new Uint8Array(enc));

return [encStr, ivStr];
}

export async function decryptWithPassword(ciphertext: string, iv: string, password: string) {
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8');

const alg = { name: 'AES-GCM', iv: base64.toByteArray(iv), length: 256 };

const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);

const key = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: await crypto.subtle.digest('SHA-256', encoder.encode(password)),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
alg,
false,
['decrypt']
);

const dec = await crypto.subtle.decrypt(alg, key, base64.toByteArray(ciphertext));
return decoder.decode(dec);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Paste" ADD COLUMN "passwordProtected" BOOLEAN NOT NULL DEFAULT false;
19 changes: 10 additions & 9 deletions src/lib/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ datasource db {
}

model Paste {
id BigInt @id @default(autoincrement())
createdAt DateTime @default(now())
key String @unique
content String
encrypted Boolean @default(false)
language String @default("plaintext")
expiresAt DateTime?
expiresCount Int?
readCount Int @default(0)
id BigInt @id @default(autoincrement())
createdAt DateTime @default(now())
key String @unique
content String
encrypted Boolean @default(false)
passwordProtected Boolean @default(false)
language String @default("plaintext")
expiresAt DateTime?
expiresCount Int?
readCount Int @default(0)
}
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface PasteConfig {
export interface Paste {
content: string;
config: PasteConfig;
passwordProtected: boolean;
}

export interface PasteCreateResponse {
Expand Down
31 changes: 16 additions & 15 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import type { Paste, PasteConfig, PasteCreateResponse } from '$lib/types';
import { onMount } from 'svelte';
import Select from 'svelte-select';
import { encrypt, encryptWithPassword } from '$lib/crypto';
const initialConfig: PasteConfig = {
language: 'plaintext',
Expand All @@ -22,15 +23,15 @@
let _sessionStorage: Storage | undefined;
$: if (_sessionStorage) {
const pasteData: Paste = { content, config };
const pasteData: { content: string; config: PasteConfig } = { content, config };
_sessionStorage.setItem('contentBackup', JSON.stringify(pasteData));
}
onMount(() => {
_sessionStorage = sessionStorage;
const contentBackup = _sessionStorage.getItem('contentBackup');
if (contentBackup) {
const data: Paste = JSON.parse(contentBackup);
const data: { content: string; config: PasteConfig } = JSON.parse(contentBackup);
content = data.content;
config = data.config;
}
Expand Down Expand Up @@ -68,25 +69,25 @@
let finalContent = content;
let urlParams = '';
let passwordProtected = false;
if (config.encrypted) {
const _sodium = (await import('libsodium-wrappers')).default;
await _sodium.ready;
const sodium = _sodium;
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const key = sodium.crypto_secretbox_keygen();
const encrypted = sodium.crypto_secretbox_easy(content, nonce, key);
finalContent = sodium.to_base64(encrypted);
const nonceKey = sodium.to_base64(nonce) + ';' + sodium.to_base64(key);
urlParams = `k=${encodeURIComponent(nonceKey)}`;
if (password) {
passwordProtected = true;
const [encryptedContent, key] = await encryptWithPassword(content, password);
finalContent = encryptedContent;
urlParams = `k=${encodeURIComponent(key)}`;
} else {
const [encryptedContent, key] = await encrypt(content);
finalContent = encryptedContent;
urlParams = `k=${encodeURIComponent(key)}`;
}
}
const data: Paste = {
content: finalContent,
config
config,
passwordProtected
};
try {
Expand Down
5 changes: 3 additions & 2 deletions src/routes/[key]/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function load({ params }) {
data: { readCount: { increment: 1 } }
});

let { content, language, encrypted } = data;
let { content, language, encrypted, passwordProtected } = data;

let contentHtml: string;

Expand All @@ -40,6 +40,7 @@ export async function load({ params }) {
content,
contentHtml,
encrypted,
language
language,
passwordProtected
};
}
90 changes: 61 additions & 29 deletions src/routes/[key]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
let Prism: any;
export let data: PageData;
let { content, contentHtml, language, encrypted } = data;
let isSafe = true;
let { content, contentHtml, language, encrypted, passwordProtected } = data;
let password = '';
let isDecrypted = false;
let codeRef: HTMLElement;
let pwInputRef: HTMLInputElement;
let error: string;
$: if (isDecrypted && codeRef) {
(async () => {
Expand All @@ -27,6 +29,8 @@
navigator.platform?.toLowerCase().startsWith('mac');
cmdKey = isMac ? '' : 'Ctrl';
pwInputRef?.focus();
document.addEventListener('keydown', (e) => {
if (e.key === 'n' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
Expand All @@ -42,39 +46,45 @@
e.preventDefault();
copyContent();
}
if (encrypted && passwordProtected && !isDecrypted && e.key === 'Enter') {
e.preventDefault();
decryptPassword();
}
});
if (encrypted) {
if (encrypted && !passwordProtected) {
contentHtml = 'Decrypting...';
(async () => {
const _sodium = (await import('libsodium-wrappers')).default;
try {
await _sodium.ready;
const sodium = _sodium;
const nonceKeyB64 = $page.url.searchParams.get('k');
if (!nonceKeyB64) throw new Error('Missing key');
const [nonceB64, keyB64] = decodeURIComponent(nonceKeyB64).split(';');
const nonce = sodium.from_base64(nonceB64);
const key = sodium.from_base64(keyB64);
const decrypted = sodium.crypto_secretbox_open_easy(
sodium.from_base64(content),
nonce,
key
);
content = sodium.to_string(decrypted);
isSafe = false;
isDecrypted = true;
const ivKey = $page.url.searchParams.get('k');
if (!ivKey) throw new Error('Missing key');
const { decrypt } = await import('$lib/crypto');
content = await decrypt(content, ivKey);
} catch (e) {
console.error(e);
contentHtml = 'Failed to decrypt';
error = 'Failed to decrypt';
} finally {
isDecrypted = true;
}
})();
}
});
async function decryptPassword() {
try {
const ivKey = $page.url.searchParams.get('k');
if (!ivKey) throw new Error('Missing key');
const { decryptWithPassword } = await import('$lib/crypto');
content = await decryptWithPassword(content, ivKey, password);
} catch (e) {
error = 'Failed to decrypt';
} finally {
isDecrypted = true;
}
}
function copyContent() {
navigator.clipboard.writeText(content);
}
Expand Down Expand Up @@ -110,14 +120,36 @@
</div>
</div>

<div class="whitespace-pre bg-dark p-4 overflow-x-scroll">
{#if isSafe}
{#if !encrypted}
<div class="grow whitespace-pre bg-dark p-4 overflow-x-scroll">
{@html contentHtml}
{:else}
</div>
{:else if error}
<div class="md:mt-10 text-center text-lg">
{error}
</div>
{:else if passwordProtected && !isDecrypted}
<div class="flex flex-col items-center gap-4 md:mt-10">
<input
class="md:w-1/3 text-lg px-4 py-1 bg-dark text-white"
type="text"
placeholder="Enter password..."
bind:this={pwInputRef}
bind:value={password}
/>
<button
class="md:w-fit btn bg-amber-500 text-black text-lg px-4 py-1"
on:click={decryptPassword}
>
Decrypt
</button>
</div>
{:else}
<div class="grow whitespace-pre bg-dark p-4 overflow-x-scroll">
<pre><code bind:this={codeRef} class="language-{language}">{content}</code></pre>
<AutoLoader languagesPath="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/" />
{/if}
</div>
</div>
{/if}

<svelte:head>
<link
Expand Down
7 changes: 4 additions & 3 deletions src/routes/api/create/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { Paste, PasteCreateResponse } from '$lib/types';
import prisma from '@db';

/** @type {RequestHandler} */
export async function POST({ request, getClientAddress }) {
const { content, config }: Paste = await request.json();
export async function POST({ request }) {
const { content, config, passwordProtected }: Paste = await request.json();

let attempts = 0;
let keyLength = 5;
Expand All @@ -23,7 +23,8 @@ export async function POST({ request, getClientAddress }) {
key,
content,
language: config.language,
encrypted: config.encrypted
encrypted: config.encrypted,
passwordProtected
}
});

Expand Down
Loading

0 comments on commit c99aaa1

Please sign in to comment.