use base64 instead of hex and refactor a bit
This commit is contained in:
parent
15db684a93
commit
f60951e7d5
|
@ -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(),
|
||||
}
|
|
@ -11,6 +11,10 @@ export type NotePublic = Pick<Note, 'contents' | 'meta'>
|
|||
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
|
||||
|
||||
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
|
||||
contents: Blob
|
||||
}
|
||||
|
||||
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
|
||||
contents: string
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
return window.crypto.getRandomValues(new Uint8Array(size))
|
||||
static async fromString(s: string): Promise<ArrayBuffer> {
|
||||
return fetch(s)
|
||||
.then((r) => r.blob())
|
||||
.then((b) => b.arrayBuffer())
|
||||
}
|
||||
}
|
||||
|
||||
export function getKeyFromString(password: string) {
|
||||
return window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
)
|
||||
}
|
||||
export class Crypto {
|
||||
private static ALG = 'AES-GCM'
|
||||
private static DELIMITER = ':::'
|
||||
|
||||
export async function getDerivedForKey(key: CryptoKey, salt: ArrayBuffer) {
|
||||
const iterations = 100_000
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations,
|
||||
hash: 'SHA-512',
|
||||
},
|
||||
key,
|
||||
{ name: ALG, length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
public static getRandomBytes(size: number): Uint8Array {
|
||||
return window.crypto.getRandomValues(new Uint8Array(size))
|
||||
}
|
||||
|
||||
export async function encrypt(plaintext: string, key: CryptoKey) {
|
||||
const salt = getRandomBytes(16)
|
||||
const derived = await getDerivedForKey(key, salt)
|
||||
const iv = getRandomBytes(16)
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{ name: ALG, iv },
|
||||
derived,
|
||||
new TextEncoder().encode(plaintext)
|
||||
)
|
||||
return [salt, iv, encrypted].map(Hex.encode).join(':')
|
||||
}
|
||||
public static getKeyFromString(password: string) {
|
||||
return window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
)
|
||||
}
|
||||
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) {
|
||||
const [salt, iv, encrypted] = ciphertext.split(':').map(Hex.decode)
|
||||
const derived = await getDerivedForKey(key, salt)
|
||||
const plaintext = await window.crypto.subtle.decrypt({ name: ALG, iv }, derived, encrypted)
|
||||
return new TextDecoder().decode(plaintext)
|
||||
public static async encrypt(plaintext: ArrayBuffer, key: CryptoKey): Promise<string> {
|
||||
const salt = this.getRandomBytes(16)
|
||||
const derived = await this.getDerivedForKey(key, salt)
|
||||
const iv = this.getRandomBytes(16)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -1,38 +1,32 @@
|
|||
<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 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 = ''
|
||||
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) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input?.files?.length) {
|
||||
files = [...files, ...Array.from(input.files)]
|
||||
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', '')
|
||||
files = [...files, ...Array.from(input.files).map(fileToDTO)]
|
||||
}
|
||||
}
|
||||
|
||||
function clear(e: Event) {
|
||||
e.preventDefault()
|
||||
files = []
|
||||
dispatch('file', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -57,7 +51,9 @@
|
|||
<div>
|
||||
<b>{$t('file_upload.no_files_selected')}</b>
|
||||
<br />
|
||||
<small>{$t('common.max')}: <MaxSize /></small>
|
||||
<small>
|
||||
{$t('common.max')}: <MaxSize />
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -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>
|
|
@ -1,20 +1,24 @@
|
|||
<script lang="ts" context="module">
|
||||
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { FileDTO, NotePublic } from '$lib/api'
|
||||
import { Files } from '$lib/files'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { saveAs } from 'file-saver'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
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
|
||||
let files: FileDTO[] = []
|
||||
|
||||
$: if (note.meta.type === 'file') {
|
||||
files = JSON.parse(note.contents) as FileDTO[]
|
||||
files = note.contents
|
||||
}
|
||||
|
||||
$: download = () => {
|
||||
|
@ -24,7 +28,7 @@
|
|||
}
|
||||
|
||||
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,
|
||||
})
|
||||
saveAs(f)
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { getRandomBytes, Hex } from '$lib/crypto'
|
||||
import copyToClipboard from 'copy-to-clipboard'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
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 value: any
|
||||
|
@ -32,7 +33,7 @@
|
|||
notify($t('home.copied_to_clipboard'))
|
||||
}
|
||||
function randomFN() {
|
||||
value = Hex.encode(getRandomBytes(20))
|
||||
value = Hex.encode(Crypto.getRandomBytes(20))
|
||||
}
|
||||
|
||||
function notify(msg: string, delay: number = 2000) {
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
<script lang="ts">
|
||||
import type { Note } from '$lib/api'
|
||||
import { create,PayloadToLargeError } from '$lib/api'
|
||||
import { encrypt,getKeyFromString,getRandomBytes,Hex } from '$lib/crypto'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import { blur } from 'svelte/transition'
|
||||
|
||||
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 Button from '$lib/ui/Button.svelte'
|
||||
import FileUpload from '$lib/ui/FileUpload.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 TextArea from '$lib/ui/TextArea.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import { blur } from 'svelte/transition'
|
||||
|
||||
let note: Note = {
|
||||
contents: '',
|
||||
|
@ -18,11 +21,12 @@
|
|||
views: 1,
|
||||
expiration: 60,
|
||||
}
|
||||
let result: { password: string; id: string } | null = null
|
||||
let files: FileDTO[]
|
||||
let result: NoteResult | null = null
|
||||
let advanced = false
|
||||
let file = false
|
||||
let isFile = false
|
||||
let timeExpiration = false
|
||||
let message = ''
|
||||
let description = ''
|
||||
let loading = false
|
||||
let error: string | null = null
|
||||
|
||||
|
@ -32,7 +36,7 @@
|
|||
}
|
||||
|
||||
$: {
|
||||
message = $t('home.explanation', {
|
||||
description = $t('home.explanation', {
|
||||
values: {
|
||||
type: $t(timeExpiration ? 'common.minutes' : 'common.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 = ''
|
||||
}
|
||||
|
||||
|
@ -53,13 +57,21 @@
|
|||
try {
|
||||
error = null
|
||||
loading = true
|
||||
const password = Hex.encode(getRandomBytes(32))
|
||||
const key = await getKeyFromString(password)
|
||||
if (note.contents === '') throw new EmptyContentError()
|
||||
|
||||
const password = Hex.encode(Crypto.getRandomBytes(32))
|
||||
const key = await Crypto.getKeyFromString(password)
|
||||
|
||||
const data: Note = {
|
||||
contents: await encrypt(note.contents, key),
|
||||
contents: '',
|
||||
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)
|
||||
else data.views = parseInt(note.views as any)
|
||||
|
||||
|
@ -74,46 +86,31 @@
|
|||
} else if (e instanceof EmptyContentError) {
|
||||
error = $t('home.errors.empty_content')
|
||||
} else {
|
||||
console.error(e)
|
||||
error = $t('home.errors.note_error')
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if result}
|
||||
<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>
|
||||
<Result {result} />
|
||||
{:else}
|
||||
<p>
|
||||
{@html $status?.theme_text || $t('home.intro')}
|
||||
</p>
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<fieldset disabled={loading}>
|
||||
{#if file}
|
||||
<FileUpload label={$t('common.file')} on:file={(f) => (note.contents = f.detail)} />
|
||||
{#if isFile}
|
||||
<FileUpload label={$t('common.file')} bind:files />
|
||||
{:else}
|
||||
<TextArea label={$t('common.note')} bind:value={note.contents} placeholder="..." />
|
||||
{/if}
|
||||
|
||||
<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}
|
||||
<Switch label={$t('common.advanced')} bind:value={advanced} />
|
||||
{/if}
|
||||
|
@ -134,7 +131,7 @@
|
|||
{#if loading}
|
||||
{$t('common.loading')}
|
||||
{:else}
|
||||
{message}
|
||||
{description}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" context="module">
|
||||
import { init, waitLocale, getLocaleFromNavigator } from 'svelte-intl-precompile'
|
||||
import { getLocaleFromNavigator, init, waitLocale } from 'svelte-intl-precompile'
|
||||
// @ts-ignore
|
||||
import { registerAll } from '$locales'
|
||||
registerAll()
|
||||
|
|
|
@ -12,25 +12,25 @@
|
|||
import { onMount } from 'svelte'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import type { NotePublic } from '$lib/api'
|
||||
import { Adapters } from '$lib/adapters'
|
||||
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 ShowNote from '$lib/ui/ShowNote.svelte'
|
||||
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
|
||||
|
||||
export let id: string
|
||||
|
||||
let password: string
|
||||
let note: NotePublic | null = null
|
||||
let note: DecryptedNote | null = null
|
||||
let exists = false
|
||||
|
||||
let loading = true
|
||||
let error = false
|
||||
let error: string | null = null
|
||||
|
||||
onMount(async () => {
|
||||
// Check if note exists
|
||||
try {
|
||||
loading = true
|
||||
error = false
|
||||
password = window.location.hash.slice(1)
|
||||
await info(id)
|
||||
exists = true
|
||||
|
@ -41,16 +41,34 @@
|
|||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the actual contents of the note and decrypt it.
|
||||
*/
|
||||
async function show() {
|
||||
try {
|
||||
error = false
|
||||
error = null
|
||||
loading = true
|
||||
const data = note || (await get(id)) // Don't get the content twice on wrong password.
|
||||
const key = await getKeyFromString(password)
|
||||
data.contents = await decrypt(data.contents, key)
|
||||
note = data
|
||||
const data = await get(id)
|
||||
const key = await Crypto.getKeyFromString(password)
|
||||
switch (data.meta.type) {
|
||||
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 {
|
||||
error = true
|
||||
error = $t('show.errors.decryption_failed')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
@ -70,7 +88,7 @@
|
|||
{#if error}
|
||||
<br />
|
||||
<p class="error-text">
|
||||
{$t('show.errors.decryption_failed')}
|
||||
{error}
|
||||
<br />
|
||||
</p>
|
||||
{/if}
|
||||
|
|
Loading…
Reference in New Issue