Derive login hash in background

This commit is contained in:
WardPearce
2026-02-14 05:02:07 +13:00
parent 4f5da73d57
commit c151198e94
5 changed files with 126 additions and 82 deletions
+7
View File
@@ -27,6 +27,7 @@
"buffer": "^6.0.3",
"capacitor-music-controls-plugin": "^6.1.0",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
"comlink": "^4.4.2",
"cookie-signature": "^1.2.2",
"dayjs": "^1.11.19",
"dexie": "^4.2.1",
@@ -6340,6 +6341,12 @@
"node": ">= 0.8"
}
},
"node_modules/comlink": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
"license": "Apache-2.0"
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+1
View File
@@ -72,6 +72,7 @@
"buffer": "^6.0.3",
"capacitor-music-controls-plugin": "^6.1.0",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
"comlink": "^4.4.2",
"cookie-signature": "^1.2.2",
"dayjs": "^1.11.19",
"dexie": "^4.2.1",
+10 -16
View File
@@ -111,22 +111,19 @@ export async function postSubscribeBackend(authorId: string) {
if (resp.ok) parseChannelRSS(authorId);
}
export type DerivePassword = (rawPassword: string, passwordSalt: Uint8Array) => Promise<Uint8Array>;
export async function createUserBackend(
username: string,
rawPassword: string,
captchaPayload: string
captchaPayload: string,
derivePassword: DerivePassword
): Promise<boolean> {
await sodium.ready;
const passwordSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const loginHash = sodium.crypto_pwhash(
32,
rawPassword,
passwordSalt,
sodium.crypto_pwhash_OPSLIMIT_SENSITIVE,
sodium.crypto_pwhash_MEMLIMIT_SENSITIVE,
sodium.crypto_pwhash_ALG_DEFAULT
);
const loginHash = await derivePassword(rawPassword, passwordSalt);
const decryptionKeySalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const rawDecryptionKey = sodium.crypto_pwhash(
@@ -175,7 +172,8 @@ export async function createUserBackend(
export async function loginUserBackend(
username: string,
rawPassword: string,
captchaPayload: string
captchaPayload: string,
derivePassword: DerivePassword
): Promise<boolean> {
await sodium.ready;
@@ -184,13 +182,9 @@ export async function loginUserBackend(
const passwordSalts = await passwordSaltsResp.json();
const loginHash = sodium.crypto_pwhash(
32,
const loginHash = await derivePassword(
rawPassword,
sodium.from_base64(passwordSalts.passwordSalt),
sodium.crypto_pwhash_OPSLIMIT_SENSITIVE,
sodium.crypto_pwhash_MEMLIMIT_SENSITIVE,
sodium.crypto_pwhash_ALG_DEFAULT
sodium.from_base64(passwordSalts.passwordSalt)
);
const loginResp = await fetch('/api/user/login', {
@@ -1,9 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { createUserBackend, loginUserBackend } from '$lib/api/backend';
import { createUserBackend, loginUserBackend, type DerivePassword } from '$lib/api/backend';
import PageLoading from '$lib/components/PageLoading.svelte';
import { _ } from '$lib/i18n';
import 'altcha';
import * as comlink from 'comlink';
import { onMount } from 'svelte';
let needToRegister = $state(false);
@@ -11,88 +14,110 @@
let rawPassword = $state('');
let captchaPayload = $state('');
let worker: Worker | undefined;
let derivePassword: DerivePassword;
let isLoading = $state(false);
onMount(() => {
worker = new Worker(new URL('./workers/derivePassword.ts', import.meta.url), {
type: 'module'
});
const workerApi = comlink.wrap(worker);
derivePassword = (workerApi as any).derivePassword as DerivePassword;
});
let failed = $state(false);
async function onLogin(event: Event) {
event.preventDefault();
isLoading = true;
if (needToRegister) {
failed = !(await createUserBackend(username, rawPassword, captchaPayload));
failed = !(await createUserBackend(username, rawPassword, captchaPayload, derivePassword));
} else {
failed = !(await loginUserBackend(username, rawPassword, captchaPayload));
failed = !(await loginUserBackend(username, rawPassword, captchaPayload, derivePassword));
}
isLoading = false;
if (!failed) {
goto(resolve('/', {}), { replaceState: true });
}
}
</script>
<nav class="center-align">
<article class="padding left-align">
<h3>{$_(needToRegister ? 'createAccount' : 'login')}</h3>
<p class="no-margin">{$_(needToRegister ? 'materialiousCreate' : 'materialiousLogin')}</p>
{#if isLoading}
<PageLoading />
{:else}
<nav class="center-align">
<article class="padding left-align">
<h3>{$_(needToRegister ? 'createAccount' : 'login')}</h3>
<p class="no-margin">{$_(needToRegister ? 'materialiousCreate' : 'materialiousLogin')}</p>
<form onsubmit={onLogin}>
<div
class="field label prefix surface-container-highest"
class:invalid={failed && needToRegister}
>
<i>person</i>
<input bind:value={username} name="username" type="text" />
<label for="username">{$_('username')}</label>
{#if failed && needToRegister}
<output class="invalid">{$_('usernameTaken')}</output>
{/if}
</div>
<div
class="field label prefix surface-container-highest"
class:invalid={failed && !needToRegister}
>
<i>password</i>
<input bind:value={rawPassword} name="password" type="password" />
<label for="password">{$_('password')}</label>
{#if failed && !needToRegister}
<output class="invalid">{$_('invalidPassword')}</output>
{/if}
</div>
<article
class="surface-container-highest no-padding"
style="width: 100%;height: fit-content;"
>
<altcha-widget
challengeurl="/api/captcha"
hidelogo
hidefooter
onstatechange={(ev) => {
const { payload, state } = ev.detail;
if (state === 'verified' && payload) {
captchaPayload = payload;
}
}}
></altcha-widget>
</article>
<nav class="right-align">
<button
type="button"
class="secondary"
onclick={() => {
needToRegister = !needToRegister;
failed = false;
}}
<form onsubmit={onLogin}>
<div
class="field label prefix surface-container-highest"
class:invalid={failed && needToRegister}
>
<span>{$_(!needToRegister ? 'needRegister' : 'needLogin')}</span>
</button>
<i>person</i>
<input bind:value={username} name="username" type="text" />
<label for="username">{$_('username')}</label>
{#if failed && needToRegister}
<output class="invalid">{$_('usernameTaken')}</output>
{/if}
</div>
<div
class="field label prefix surface-container-highest"
class:invalid={failed && !needToRegister}
>
<i>password</i>
<input bind:value={rawPassword} name="password" type="password" />
<label for="password">{$_('password')}</label>
{#if failed && !needToRegister}
<output class="invalid">{$_('invalidPassword')}</output>
{/if}
</div>
<button type="submit">
<i>done</i>
<span>{$_(needToRegister ? 'createAccount' : 'login')}</span>
</button>
</nav>
</form>
</article>
</nav>
<article
class="surface-container-highest no-padding"
style="width: 100%;height: fit-content;"
>
<altcha-widget
challengeurl="/api/captcha"
hidelogo
hidefooter
onstatechange={(ev) => {
const { payload, state } = ev.detail;
if (state === 'verified' && payload) {
captchaPayload = payload;
}
}}
></altcha-widget>
</article>
<nav class="right-align">
<button
type="button"
class="secondary"
onclick={() => {
needToRegister = !needToRegister;
failed = false;
}}
>
<span>{$_(!needToRegister ? 'needRegister' : 'needLogin')}</span>
</button>
<button type="submit">
<i>done</i>
<span>{$_(needToRegister ? 'createAccount' : 'login')}</span>
</button>
</nav>
</form>
</article>
</nav>
{/if}
<style>
article {
@@ -0,0 +1,17 @@
import * as comlink from 'comlink';
import sodium from 'libsodium-wrappers-sumo';
async function derivePassword(rawPassword: string, passwordSalt: Uint8Array): Promise<Uint8Array> {
await sodium.ready;
return sodium.crypto_pwhash(
32,
rawPassword,
passwordSalt,
sodium.crypto_pwhash_OPSLIMIT_SENSITIVE,
sodium.crypto_pwhash_MEMLIMIT_SENSITIVE,
sodium.crypto_pwhash_ALG_DEFAULT
);
}
comlink.expose({ derivePassword });