11 changed files with 400 additions and 1 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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