use base64 instead of hex and refactor a bit

This commit is contained in:
cupcakearmy 2022-07-18 23:49:13 +02:00
parent 15db684a93
commit f60951e7d5
No known key found for this signature in database
GPG Key ID: 3235314B4D31232F
11 changed files with 266 additions and 136 deletions

View File

@ -0,0 +1,61 @@
import type { EncryptedFileDTO, FileDTO } from './api'
import { Crypto } from './crypto'
abstract class CryptAdapter<T> {
abstract encrypt(plaintext: T, key: CryptoKey): Promise<string>
abstract decrypt(ciphertext: string, key: CryptoKey): Promise<T>
}
class CryptTextAdapter implements CryptAdapter<string> {
async encrypt(plaintext: string, key: CryptoKey) {
return await Crypto.encrypt(new TextEncoder().encode(plaintext), key)
}
async decrypt(ciphertext: string, key: CryptoKey) {
const plaintext = await Crypto.decrypt(ciphertext, key)
return new TextDecoder().decode(plaintext)
}
}
class CryptBlobAdapter implements CryptAdapter<Blob> {
async encrypt(plaintext: Blob, key: CryptoKey) {
return await Crypto.encrypt(await plaintext.arrayBuffer(), key)
}
async decrypt(ciphertext: string, key: CryptoKey) {
const plaintext = await Crypto.decrypt(ciphertext, key)
return new Blob([plaintext], { type: 'application/octet-stream' })
}
}
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
async encrypt(plaintext: FileDTO[], key: CryptoKey) {
const adapter = new CryptBlobAdapter()
const data: Promise<EncryptedFileDTO>[] = plaintext.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.encrypt(file.contents, key),
}))
return JSON.stringify(await Promise.all(data))
}
async decrypt(ciphertext: string, key: CryptoKey) {
const adapter = new CryptBlobAdapter()
const data: EncryptedFileDTO[] = JSON.parse(ciphertext)
const files: FileDTO[] = await Promise.all(
data.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.decrypt(file.contents, key),
}))
)
return files
}
}
export const Adapters = {
Text: new CryptTextAdapter(),
Blob: new CryptBlobAdapter(),
Files: new CryptFilesAdapter(),
}

View File

@ -11,6 +11,10 @@ export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string } export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & { export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
contents: Blob
}
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
contents: string contents: string
} }

View File

@ -19,53 +19,79 @@ export class Hex {
} }
} }
const ALG = 'AES-GCM' export class ArrayBufferUtils {
static async toString(buffer: ArrayBuffer): Promise<string> {
const reader = new window.FileReader()
reader.readAsDataURL(new Blob([buffer]))
return new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string)
})
}
export function getRandomBytes(size = 16): Uint8Array { static async fromString(s: string): Promise<ArrayBuffer> {
return window.crypto.getRandomValues(new Uint8Array(size)) return fetch(s)
.then((r) => r.blob())
.then((b) => b.arrayBuffer())
}
} }
export function getKeyFromString(password: string) { export class Crypto {
return window.crypto.subtle.importKey( private static ALG = 'AES-GCM'
'raw', private static DELIMITER = ':::'
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
)
}
export async function getDerivedForKey(key: CryptoKey, salt: ArrayBuffer) { public static getRandomBytes(size: number): Uint8Array {
const iterations = 100_000 return window.crypto.getRandomValues(new Uint8Array(size))
return window.crypto.subtle.deriveKey( }
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-512',
},
key,
{ name: ALG, length: 256 },
true,
['encrypt', 'decrypt']
)
}
export async function encrypt(plaintext: string, key: CryptoKey) { public static getKeyFromString(password: string) {
const salt = getRandomBytes(16) return window.crypto.subtle.importKey(
const derived = await getDerivedForKey(key, salt) 'raw',
const iv = getRandomBytes(16) new TextEncoder().encode(password),
const encrypted = await window.crypto.subtle.encrypt( 'PBKDF2',
{ name: ALG, iv }, false,
derived, ['deriveBits', 'deriveKey']
new TextEncoder().encode(plaintext) )
) }
return [salt, iv, encrypted].map(Hex.encode).join(':') public static async getDerivedForKey(key: CryptoKey, salt: ArrayBuffer) {
} const iterations = 100_000
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-512',
},
key,
{ name: this.ALG, length: 256 },
true,
['encrypt', 'decrypt']
)
}
export async function decrypt(ciphertext: string, key: CryptoKey) { public static async encrypt(plaintext: ArrayBuffer, key: CryptoKey): Promise<string> {
const [salt, iv, encrypted] = ciphertext.split(':').map(Hex.decode) const salt = this.getRandomBytes(16)
const derived = await getDerivedForKey(key, salt) const derived = await this.getDerivedForKey(key, salt)
const plaintext = await window.crypto.subtle.decrypt({ name: ALG, iv }, derived, encrypted) const iv = this.getRandomBytes(16)
return new TextDecoder().decode(plaintext) const encrypted: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: this.ALG, iv },
derived,
plaintext
)
const data = [
Hex.encode(salt),
Hex.encode(iv),
await ArrayBufferUtils.toString(encrypted),
].join(this.DELIMITER)
return data
}
public static async decrypt(ciphertext: string, key: CryptoKey): Promise<ArrayBuffer> {
const splitted = ciphertext.split(this.DELIMITER)
const salt = Hex.decode(splitted[0])
const iv = Hex.decode(splitted[1])
const encrypted = await ArrayBufferUtils.fromString(splitted[2])
const derived = await this.getDerivedForKey(key, salt)
const plaintext = await window.crypto.subtle.decrypt({ name: this.ALG, iv }, derived, encrypted)
return plaintext
}
} }

View File

@ -1,13 +0,0 @@
export class Files {
static toString(f: File | Blob): Promise<string> {
const reader = new window.FileReader()
reader.readAsDataURL(f)
return new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string)
})
}
static async fromString(s: string): Promise<Blob> {
return fetch(s).then((r) => r.blob())
}
}

View File

@ -1,38 +1,32 @@
<script lang="ts"> <script lang="ts">
import type { FileDTO } from '$lib/api'
import { Files } from '$lib/files'
import { createEventDispatcher } from 'svelte'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import Button from './Button.svelte'
import MaxSize from './MaxSize.svelte' import type { FileDTO } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
export let label: string = '' export let label: string = ''
let files: File[] = [] export let files: FileDTO[] = []
const dispatch = createEventDispatcher<{ file: string }>() function fileToDTO(file: File): FileDTO {
return {
name: file.name,
size: file.size,
type: file.type,
contents: file,
}
}
async function onInput(e: Event) { async function onInput(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
if (input?.files?.length) { if (input?.files?.length) {
files = [...files, ...Array.from(input.files)] files = [...files, ...Array.from(input.files).map(fileToDTO)]
const data: FileDTO[] = await Promise.all(
files.map(async (file) => ({
name: file.name,
type: file.type,
size: file.size,
contents: await Files.toString(file),
}))
)
dispatch('file', JSON.stringify(data))
} else {
dispatch('file', '')
} }
} }
function clear(e: Event) { function clear(e: Event) {
e.preventDefault() e.preventDefault()
files = [] files = []
dispatch('file', '')
} }
</script> </script>
@ -57,7 +51,9 @@
<div> <div>
<b>{$t('file_upload.no_files_selected')}</b> <b>{$t('file_upload.no_files_selected')}</b>
<br /> <br />
<small>{$t('common.max')}: <MaxSize /></small> <small>
{$t('common.max')}: <MaxSize />
</small>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -0,0 +1,36 @@
<script lang="ts" context="module">
export type NoteResult = {
password: string
id: string
}
</script>
<script lang="ts">
import { t } from 'svelte-intl-precompile'
import Button from '$lib/ui/Button.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
export let result: NoteResult
function reset() {
window.location.reload()
}
</script>
<TextInput
type="text"
readonly
label={$t('common.share_link')}
value="{window.location.origin}/note/{result.id}#{result.password}"
copy
/>
<br />
<p>
{@html $t('home.new_note_notice')}
</p>
<br />
<Button on:click={reset}>{$t('home.new_note')}</Button>
<style>
</style>

View File

@ -1,20 +1,24 @@
<script lang="ts" context="module">
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
</script>
<script lang="ts"> <script lang="ts">
import type { FileDTO, NotePublic } from '$lib/api'
import { Files } from '$lib/files'
import copy from 'copy-to-clipboard' import copy from 'copy-to-clipboard'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import prettyBytes from 'pretty-bytes' import prettyBytes from 'pretty-bytes'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import Button from './Button.svelte'
export let note: NotePublic import type { FileDTO, NotePublic } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
export let note: DecryptedNote
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
let files: FileDTO[] = [] let files: FileDTO[] = []
$: if (note.meta.type === 'file') { $: if (note.meta.type === 'file') {
files = JSON.parse(note.contents) as FileDTO[] files = note.contents
} }
$: download = () => { $: download = () => {
@ -24,7 +28,7 @@
} }
async function downloadFile(file: FileDTO) { async function downloadFile(file: FileDTO) {
const f = new File([await Files.fromString(file.contents)], file.name, { const f = new File([file.contents], file.name, {
type: file.type, type: file.type,
}) })
saveAs(f) saveAs(f)

View File

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { getRandomBytes, Hex } from '$lib/crypto'
import copyToClipboard from 'copy-to-clipboard' import copyToClipboard from 'copy-to-clipboard'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import { fade } from 'svelte/transition' import { fade } from 'svelte/transition'
import Icon from './Icon.svelte'
import { Crypto, Hex } from '$lib/crypto'
import Icon from '$lib/ui/Icon.svelte'
export let label: string = '' export let label: string = ''
export let value: any export let value: any
@ -32,7 +33,7 @@
notify($t('home.copied_to_clipboard')) notify($t('home.copied_to_clipboard'))
} }
function randomFN() { function randomFN() {
value = Hex.encode(getRandomBytes(20)) value = Hex.encode(Crypto.getRandomBytes(20))
} }
function notify(msg: string, delay: number = 2000) { function notify(msg: string, delay: number = 2000) {

View File

@ -1,16 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { Note } from '$lib/api' import { t } from 'svelte-intl-precompile'
import { create,PayloadToLargeError } from '$lib/api' import { blur } from 'svelte/transition'
import { encrypt,getKeyFromString,getRandomBytes,Hex } from '$lib/crypto'
import { Adapters } from '$lib/adapters'
import type { FileDTO, Note } from '$lib/api'
import { create, PayloadToLargeError } from '$lib/api'
import { Crypto, Hex } from '$lib/crypto'
import { status } from '$lib/stores/status' import { status } from '$lib/stores/status'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import FileUpload from '$lib/ui/FileUpload.svelte' import FileUpload from '$lib/ui/FileUpload.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte' import MaxSize from '$lib/ui/MaxSize.svelte'
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte' import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte' import TextArea from '$lib/ui/TextArea.svelte'
import TextInput from '$lib/ui/TextInput.svelte' import TextInput from '$lib/ui/TextInput.svelte'
import { t } from 'svelte-intl-precompile'
import { blur } from 'svelte/transition'
let note: Note = { let note: Note = {
contents: '', contents: '',
@ -18,11 +21,12 @@
views: 1, views: 1,
expiration: 60, expiration: 60,
} }
let result: { password: string; id: string } | null = null let files: FileDTO[]
let result: NoteResult | null = null
let advanced = false let advanced = false
let file = false let isFile = false
let timeExpiration = false let timeExpiration = false
let message = '' let description = ''
let loading = false let loading = false
let error: string | null = null let error: string | null = null
@ -32,7 +36,7 @@
} }
$: { $: {
message = $t('home.explanation', { description = $t('home.explanation', {
values: { values: {
type: $t(timeExpiration ? 'common.minutes' : 'common.views', { type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
values: { n: (timeExpiration ? note.expiration : note.views) ?? '?' }, values: { n: (timeExpiration ? note.expiration : note.views) ?? '?' },
@ -41,9 +45,9 @@
}) })
} }
$: note.meta.type = file ? 'file' : 'text' $: note.meta.type = isFile ? 'file' : 'text'
$: if (!file) { $: if (!isFile) {
note.contents = '' note.contents = ''
} }
@ -53,13 +57,21 @@
try { try {
error = null error = null
loading = true loading = true
const password = Hex.encode(getRandomBytes(32))
const key = await getKeyFromString(password) const password = Hex.encode(Crypto.getRandomBytes(32))
if (note.contents === '') throw new EmptyContentError() const key = await Crypto.getKeyFromString(password)
const data: Note = { const data: Note = {
contents: await encrypt(note.contents, key), contents: '',
meta: note.meta, meta: note.meta,
} }
if (isFile) {
if (files.length === 0) throw new EmptyContentError()
data.contents = await Adapters.Files.encrypt(files, key)
} else {
if (note.contents === '') throw new EmptyContentError()
data.contents = await Adapters.Text.encrypt(note.contents, key)
}
if (timeExpiration) data.expiration = parseInt(note.expiration as any) if (timeExpiration) data.expiration = parseInt(note.expiration as any)
else data.views = parseInt(note.views as any) else data.views = parseInt(note.views as any)
@ -74,46 +86,31 @@
} else if (e instanceof EmptyContentError) { } else if (e instanceof EmptyContentError) {
error = $t('home.errors.empty_content') error = $t('home.errors.empty_content')
} else { } else {
console.error(e)
error = $t('home.errors.note_error') error = $t('home.errors.note_error')
} }
} finally { } finally {
loading = false loading = false
} }
} }
function reset() {
window.location.reload()
}
</script> </script>
{#if result} {#if result}
<TextInput <Result {result} />
type="text"
readonly
label={$t('common.share_link')}
value="{window.location.origin}/note/{result.id}#{result.password}"
copy
/>
<br />
<p>
{@html $t('home.new_note_notice')}
</p>
<br />
<Button on:click={reset}>{$t('home.new_note')}</Button>
{:else} {:else}
<p> <p>
{@html $status?.theme_text || $t('home.intro')} {@html $status?.theme_text || $t('home.intro')}
</p> </p>
<form on:submit|preventDefault={submit}> <form on:submit|preventDefault={submit}>
<fieldset disabled={loading}> <fieldset disabled={loading}>
{#if file} {#if isFile}
<FileUpload label={$t('common.file')} on:file={(f) => (note.contents = f.detail)} /> <FileUpload label={$t('common.file')} bind:files />
{:else} {:else}
<TextArea label={$t('common.note')} bind:value={note.contents} placeholder="..." /> <TextArea label={$t('common.note')} bind:value={note.contents} placeholder="..." />
{/if} {/if}
<div class="bottom"> <div class="bottom">
<Switch class="file" label={$t('common.file')} bind:value={file} /> <Switch class="file" label={$t('common.file')} bind:value={isFile} />
{#if $status?.allow_advanced} {#if $status?.allow_advanced}
<Switch label={$t('common.advanced')} bind:value={advanced} /> <Switch label={$t('common.advanced')} bind:value={advanced} />
{/if} {/if}
@ -134,7 +131,7 @@
{#if loading} {#if loading}
{$t('common.loading')} {$t('common.loading')}
{:else} {:else}
{message} {description}
{/if} {/if}
</p> </p>

View File

@ -1,5 +1,5 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { init, waitLocale, getLocaleFromNavigator } from 'svelte-intl-precompile' import { getLocaleFromNavigator, init, waitLocale } from 'svelte-intl-precompile'
// @ts-ignore // @ts-ignore
import { registerAll } from '$locales' import { registerAll } from '$locales'
registerAll() registerAll()

View File

@ -12,25 +12,25 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import type { NotePublic } from '$lib/api' import { Adapters } from '$lib/adapters'
import { get, info } from '$lib/api' import { get, info } from '$lib/api'
import { decrypt, getKeyFromString } from '$lib/crypto' import { Crypto } from '$lib/crypto'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import ShowNote from '$lib/ui/ShowNote.svelte' import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
export let id: string export let id: string
let password: string let password: string
let note: NotePublic | null = null let note: DecryptedNote | null = null
let exists = false let exists = false
let loading = true let loading = true
let error = false let error: string | null = null
onMount(async () => { onMount(async () => {
// Check if note exists
try { try {
loading = true loading = true
error = false
password = window.location.hash.slice(1) password = window.location.hash.slice(1)
await info(id) await info(id)
exists = true exists = true
@ -41,16 +41,34 @@
} }
}) })
/**
* Get the actual contents of the note and decrypt it.
*/
async function show() { async function show() {
try { try {
error = false error = null
loading = true loading = true
const data = note || (await get(id)) // Don't get the content twice on wrong password. const data = await get(id)
const key = await getKeyFromString(password) const key = await Crypto.getKeyFromString(password)
data.contents = await decrypt(data.contents, key) switch (data.meta.type) {
note = data case 'text':
note = {
meta: { type: 'text' },
contents: await Adapters.Text.decrypt(data.contents, key),
}
break
case 'file':
note = {
meta: { type: 'file' },
contents: await Adapters.Files.decrypt(data.contents, key),
}
break
default:
error = $t('show.errors.unsupported_type')
return
}
} catch { } catch {
error = true error = $t('show.errors.decryption_failed')
} finally { } finally {
loading = false loading = false
} }
@ -70,7 +88,7 @@
{#if error} {#if error}
<br /> <br />
<p class="error-text"> <p class="error-text">
{$t('show.errors.decryption_failed')} {error}
<br /> <br />
</p> </p>
{/if} {/if}