11 changed files with 400 additions and 1 deletions
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
import { generateMnemonic } from "@scure/bip39"; |
||||
import { wordlist } from "@scure/bip39/wordlists/english"; |
||||
import forge from "node-forge"; |
||||
import { encode } from "universal-base64url"; |
||||
|
||||
async function seedFromMnemonic(mnemonic: string) { |
||||
const md = forge.md.sha256.create(); |
||||
md.update(mnemonic); |
||||
// TODO this is probably not correct
|
||||
return md.digest().toHex(); |
||||
} |
||||
|
||||
export async function keysFromMenmonic(mnemonic: string) { |
||||
const seed = await seedFromMnemonic(mnemonic); |
||||
|
||||
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({ |
||||
seed, |
||||
}); |
||||
|
||||
return { |
||||
privateKey, |
||||
publicKey, |
||||
}; |
||||
} |
||||
|
||||
export function genMnemonic(): string { |
||||
return generateMnemonic(wordlist); |
||||
} |
||||
|
||||
export async function signCode( |
||||
_code: string, |
||||
_privateKey: forge.pki.ed25519.NativeBuffer |
||||
): Promise<Uint8Array> { |
||||
// TODO add real signature
|
||||
return new Uint8Array(); |
||||
} |
||||
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string { |
||||
return encode(String.fromCodePoint(...bytes)); |
||||
} |
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
import { ofetch } from "ofetch"; |
||||
|
||||
export interface MetaResponse { |
||||
name: string; |
||||
description?: string; |
||||
hasCaptcha: boolean; |
||||
} |
||||
|
||||
export async function getBackendMeta(url: string): Promise<MetaResponse> { |
||||
return ofetch<MetaResponse>("/meta", { |
||||
baseURL: url, |
||||
}); |
||||
} |
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
import { ofetch } from "ofetch"; |
||||
|
||||
import { SessionResponse, UserResponse } from "@/backend/accounts/auth"; |
||||
import { keysFromMenmonic, signCode } from "@/backend/accounts/crypto"; |
||||
|
||||
export interface ChallengeTokenResponse { |
||||
challenge: string; |
||||
} |
||||
|
||||
export async function getRegisterChallengeToken( |
||||
url: string, |
||||
captchaToken?: string |
||||
): Promise<ChallengeTokenResponse> { |
||||
return ofetch<ChallengeTokenResponse>("/auth/register/start", { |
||||
method: "POST", |
||||
body: { |
||||
captchaToken, |
||||
}, |
||||
baseURL: url, |
||||
}); |
||||
} |
||||
|
||||
export interface RegisterResponse { |
||||
user: UserResponse; |
||||
session: SessionResponse; |
||||
token: string; |
||||
} |
||||
|
||||
export interface RegisterInput { |
||||
publicKey: string; |
||||
challenge: { |
||||
code: string; |
||||
signature: string; |
||||
}; |
||||
device: string; |
||||
profile: { |
||||
colorA: string; |
||||
colorB: string; |
||||
icon: string; |
||||
}; |
||||
} |
||||
|
||||
export async function registerAccount( |
||||
url: string, |
||||
data: RegisterInput |
||||
): Promise<RegisterResponse> { |
||||
return ofetch<RegisterResponse>("/auth/register/complete", { |
||||
method: "POST", |
||||
body: { |
||||
namespace: "movie-web", |
||||
...data, |
||||
}, |
||||
baseURL: url, |
||||
}); |
||||
} |
||||
|
||||
export async function signChallenge(mnemonic: string, challengeCode: string) { |
||||
const keys = await keysFromMenmonic(mnemonic); |
||||
const signature = await signCode(challengeCode, keys.privateKey); |
||||
return { |
||||
publicKey: keys.publicKey, |
||||
signature, |
||||
}; |
||||
} |
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react"; |
||||
|
||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; |
||||
import { |
||||
AccountCreatePart, |
||||
AccountProfile, |
||||
} from "@/pages/parts/auth/AccountCreatePart"; |
||||
import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePart"; |
||||
import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart"; |
||||
import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart"; |
||||
|
||||
export function RegisterPage() { |
||||
const [step, setStep] = useState(0); |
||||
const [mnemonic, setMnemonic] = useState<null | string>(null); |
||||
const [account, setAccount] = useState<null | AccountProfile>(null); |
||||
|
||||
return ( |
||||
<SubPageLayout> |
||||
{step === 0 ? ( |
||||
<TrustBackendPart |
||||
onNext={() => { |
||||
setStep(1); |
||||
}} |
||||
/> |
||||
) : null} |
||||
{step === 1 ? ( |
||||
<PassphraseGeneratePart |
||||
onNext={(n) => { |
||||
setMnemonic(n); |
||||
setStep(2); |
||||
}} |
||||
/> |
||||
) : null} |
||||
{step === 2 ? ( |
||||
<AccountCreatePart |
||||
onNext={(v) => { |
||||
setAccount(v); |
||||
setStep(3); |
||||
}} |
||||
/> |
||||
) : null} |
||||
{step === 3 ? ( |
||||
<VerifyPassphrase |
||||
mnemonic={mnemonic} |
||||
profile={account} |
||||
onNext={() => { |
||||
setStep(4); |
||||
}} |
||||
/> |
||||
) : null} |
||||
{step === 4 ? <p>Success, account now exists</p> : null} |
||||
</SubPageLayout> |
||||
); |
||||
} |
@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useState } from "react"; |
||||
|
||||
import { Button } from "@/components/Button"; |
||||
import { Input } from "@/components/player/internals/ContextMenu/Input"; |
||||
|
||||
export interface AccountProfile { |
||||
device: string; |
||||
account: string; |
||||
profile: { |
||||
colorA: string; |
||||
colorB: string; |
||||
icon: string; |
||||
}; |
||||
} |
||||
|
||||
interface AccountCreatePartProps { |
||||
onNext?: (data: AccountProfile) => void; |
||||
} |
||||
|
||||
export function AccountCreatePart(props: AccountCreatePartProps) { |
||||
const [account, setAccount] = useState(""); |
||||
const [device, setDevice] = useState(""); |
||||
// TODO validate device and account before next step
|
||||
|
||||
const nextStep = useCallback(() => { |
||||
props.onNext?.({ |
||||
account, |
||||
device, |
||||
profile: { |
||||
colorA: "#fff", |
||||
colorB: "#000", |
||||
icon: "brush", |
||||
}, |
||||
}); |
||||
}, [account, device, props]); |
||||
|
||||
return ( |
||||
<div> |
||||
<p>Account name</p> |
||||
<Input value={account} onInput={setAccount} /> |
||||
<p>Device name</p> |
||||
<Input value={device} onInput={setDevice} /> |
||||
<Button onClick={() => nextStep()}>Next</Button> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
import { useMemo } from "react"; |
||||
|
||||
import { genMnemonic } from "@/backend/accounts/crypto"; |
||||
import { Button } from "@/components/Button"; |
||||
|
||||
interface PassphraseGeneratePartProps { |
||||
onNext?: (mnemonic: string) => void; |
||||
} |
||||
|
||||
export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) { |
||||
const mnemonic = useMemo(() => genMnemonic(), []); |
||||
|
||||
return ( |
||||
<div> |
||||
<p>Remeber the following passphrase:</p> |
||||
<p className="border rounded-xl p-2">{mnemonic}</p> |
||||
<Button onClick={() => props.onNext?.(mnemonic)}>Next</Button> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
import { useAsync } from "react-use"; |
||||
|
||||
import { MetaResponse, getBackendMeta } from "@/backend/accounts/meta"; |
||||
import { Button } from "@/components/Button"; |
||||
import { conf } from "@/setup/config"; |
||||
|
||||
interface TrustBackendPartProps { |
||||
onNext?: (meta: MetaResponse) => void; |
||||
} |
||||
|
||||
export function TrustBackendPart(props: TrustBackendPartProps) { |
||||
const result = useAsync(async () => { |
||||
const url = conf().BACKEND_URL; |
||||
return { |
||||
domain: new URL(url).hostname, |
||||
data: await getBackendMeta(conf().BACKEND_URL), |
||||
}; |
||||
}, []); |
||||
|
||||
if (result.loading) return <p>loading...</p>; |
||||
|
||||
if (result.error || !result.value) |
||||
return <p>Failed to talk to backend, did you configure it correctly?</p>; |
||||
|
||||
return ( |
||||
<div> |
||||
<p> |
||||
do you trust{" "} |
||||
<span className="text-white font-bold">{result.value.domain}</span> |
||||
</p> |
||||
<div className="border rounded-xl p-4"> |
||||
<p className="text-white font-bold">{result.value.data.name}</p> |
||||
{result.value.data.description ? ( |
||||
<p>{result.value.data.description}</p> |
||||
) : null} |
||||
</div> |
||||
<Button onClick={() => props.onNext?.(result.value.data)}>Next</Button> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react"; |
||||
import { useAsyncFn } from "react-use"; |
||||
|
||||
import { bytesToBase64Url } from "@/backend/accounts/crypto"; |
||||
import { |
||||
getRegisterChallengeToken, |
||||
registerAccount, |
||||
signChallenge, |
||||
} from "@/backend/accounts/register"; |
||||
import { Button } from "@/components/Button"; |
||||
import { Input } from "@/components/player/internals/ContextMenu/Input"; |
||||
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart"; |
||||
import { conf } from "@/setup/config"; |
||||
import { useAuthStore } from "@/stores/auth"; |
||||
|
||||
interface VerifyPassphraseProps { |
||||
mnemonic: string | null; |
||||
profile: AccountProfile | null; |
||||
onNext?: () => void; |
||||
} |
||||
|
||||
export function VerifyPassphrase(props: VerifyPassphraseProps) { |
||||
const [mnemonic, setMnemonic] = useState(""); |
||||
const setAccount = useAuthStore((s) => s.setAccount); |
||||
|
||||
const [result, execute] = useAsyncFn( |
||||
async (inputMnemonic: string) => { |
||||
if (!props.mnemonic || !props.profile) |
||||
throw new Error("invalid input data"); |
||||
if (inputMnemonic !== props.mnemonic) |
||||
throw new Error("Passphrase doesn't match"); |
||||
const url = conf().BACKEND_URL; |
||||
|
||||
// TODO captcha?
|
||||
const { challenge } = await getRegisterChallengeToken(url); |
||||
const keys = await signChallenge(inputMnemonic, challenge); |
||||
const registerResult = await registerAccount(url, { |
||||
challenge: { |
||||
code: challenge, |
||||
signature: bytesToBase64Url(keys.signature), |
||||
}, |
||||
publicKey: bytesToBase64Url(keys.publicKey), |
||||
device: props.profile.device, |
||||
profile: props.profile.profile, |
||||
}); |
||||
|
||||
setAccount({ |
||||
profile: registerResult.user.profile, |
||||
sessionId: registerResult.session.id, |
||||
token: registerResult.token, |
||||
userId: registerResult.user.id, |
||||
}); |
||||
|
||||
props.onNext?.(); |
||||
}, |
||||
[props, setAccount] |
||||
); |
||||
|
||||
return ( |
||||
<div> |
||||
<p>verify passphrase</p> |
||||
<Input value={mnemonic} onInput={setMnemonic} /> |
||||
{result.loading ? <p>Loading...</p> : null} |
||||
{result.error ? <p>error: {result.error.toString()}</p> : null} |
||||
<Button onClick={() => execute(mnemonic)}>Register</Button> |
||||
</div> |
||||
); |
||||
} |
Loading…
Reference in new issue