diff --git a/package.json b/package.json index aae704ca..d499514d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "@headlessui/react": "^1.5.0", "@movie-web/providers": "^1.0.4", "@react-spring/web": "^9.7.1", + "@scure/bip39": "^1.2.1", "@sozialhelden/ietf-language-tags": "^5.4.2", + "@types/node-forge": "^1.3.8", "classnames": "^2.3.2", "core-js": "^3.29.1", "dompurify": "^3.0.1", @@ -19,6 +21,7 @@ "hls.js": "^1.0.7", "i18next": "^22.4.5", "immer": "^10.0.2", + "node-forge": "^1.3.1", "ofetch": "^1.0.0", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -30,6 +33,7 @@ "react-use": "^17.4.0", "slugify": "^1.6.6", "subsrt-ts": "^2.1.1", + "universal-base64url": "^1.1.0", "unzipit": "^1.4.3", "zustand": "^4.3.9" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a8592a8..94e4a7eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,15 @@ dependencies: '@react-spring/web': specifier: ^9.7.1 version: 9.7.3(react-dom@17.0.2)(react@17.0.2) + '@scure/bip39': + specifier: ^1.2.1 + version: 1.2.1 '@sozialhelden/ietf-language-tags': specifier: ^5.4.2 version: 5.4.2 + '@types/node-forge': + specifier: ^1.3.8 + version: 1.3.8 classnames: specifier: ^2.3.2 version: 2.3.2 @@ -56,6 +62,9 @@ dependencies: immer: specifier: ^10.0.2 version: 10.0.2 + node-forge: + specifier: ^1.3.1 + version: 1.3.1 ofetch: specifier: ^1.0.0 version: 1.3.3 @@ -89,6 +98,9 @@ dependencies: subsrt-ts: specifier: ^2.1.1 version: 2.1.1 + universal-base64url: + specifier: ^1.1.0 + version: 1.1.0 unzipit: specifier: ^1.4.3 version: 1.4.3 @@ -1889,6 +1901,11 @@ packages: - encoding dev: false + /@noble/hashes@1.3.2: + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2019,6 +2036,17 @@ packages: rollup: 2.79.1 dev: true + /@scure/base@1.1.3: + resolution: {integrity: sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==} + dev: false + + /@scure/bip39@1.2.1: + resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + dependencies: + '@noble/hashes': 1.3.2 + '@scure/base': 1.1.3 + dev: false + /@sozialhelden/ietf-language-tags@5.4.2: resolution: {integrity: sha512-aCN7bVOfX9sBN0EHyWJT14H8bx+VYBo8tdcynai35wgoxKMfVtgEECkQ1gs8nEL6GHGes8lPIfo6AjIch44N3w==} dependencies: @@ -2121,9 +2149,14 @@ packages: resolution: {integrity: sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g==} dev: true + /@types/node-forge@1.3.8: + resolution: {integrity: sha512-vGXshY9vim9CJjrpcS5raqSjEfKlJcWy2HNdgUasR66fAnVEYarrf1ULV4nfvpC1nZq/moA9qyqBcu83x+Jlrg==} + dependencies: + '@types/node': 17.0.45 + dev: false + /@types/node@17.0.45: resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} - dev: true /@types/pako@2.0.0: resolution: {integrity: sha512-10+iaz93qR5WYxTo+PMifD5TSxiOtdRaxBf7INGGXMQgTCu8Z/7GYWYFUOS3q/G0nE5boj1r4FEB+WSy7s5gbA==} @@ -4733,6 +4766,11 @@ packages: whatwg-url: 5.0.0 dev: false + /node-forge@1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + dev: false + /node-releases@2.0.13: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} dev: true @@ -6112,6 +6150,16 @@ packages: crypto-random-string: 2.0.0 dev: true + /universal-base64@2.1.0: + resolution: {integrity: sha512-WeOkACVnIXJZr/qlv7++Rl1zuZOHN96v2yS5oleUuv8eJOs5j9M5U3xQEIoWqn1OzIuIcgw0fswxWnUVGDfW6g==} + dev: false + + /universal-base64url@1.1.0: + resolution: {integrity: sha512-qWv2+8KCaAWdpqqXwU8W0Yj9pflYDXP37/a3kec6Y4Je7bYzgIfxEVRjZWeLR67be7iot1lGCy5Nuo+xB0fojA==} + dependencies: + universal-base64: 2.1.0 + dev: false + /universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} diff --git a/src/backend/accounts/crypto.ts b/src/backend/accounts/crypto.ts new file mode 100644 index 00000000..8ab25163 --- /dev/null +++ b/src/backend/accounts/crypto.ts @@ -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 { + // TODO add real signature + return new Uint8Array(); +} + +export function bytesToBase64Url(bytes: Uint8Array): string { + return encode(String.fromCodePoint(...bytes)); +} diff --git a/src/backend/accounts/meta.ts b/src/backend/accounts/meta.ts new file mode 100644 index 00000000..1747a7a3 --- /dev/null +++ b/src/backend/accounts/meta.ts @@ -0,0 +1,13 @@ +import { ofetch } from "ofetch"; + +export interface MetaResponse { + name: string; + description?: string; + hasCaptcha: boolean; +} + +export async function getBackendMeta(url: string): Promise { + return ofetch("/meta", { + baseURL: url, + }); +} diff --git a/src/backend/accounts/register.ts b/src/backend/accounts/register.ts new file mode 100644 index 00000000..93f2794d --- /dev/null +++ b/src/backend/accounts/register.ts @@ -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 { + return ofetch("/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 { + return ofetch("/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, + }; +} diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx new file mode 100644 index 00000000..f7237613 --- /dev/null +++ b/src/pages/Register.tsx @@ -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); + const [account, setAccount] = useState(null); + + return ( + + {step === 0 ? ( + { + setStep(1); + }} + /> + ) : null} + {step === 1 ? ( + { + setMnemonic(n); + setStep(2); + }} + /> + ) : null} + {step === 2 ? ( + { + setAccount(v); + setStep(3); + }} + /> + ) : null} + {step === 3 ? ( + { + setStep(4); + }} + /> + ) : null} + {step === 4 ?

Success, account now exists

: null} +
+ ); +} diff --git a/src/pages/parts/auth/AccountCreatePart.tsx b/src/pages/parts/auth/AccountCreatePart.tsx new file mode 100644 index 00000000..bdaff578 --- /dev/null +++ b/src/pages/parts/auth/AccountCreatePart.tsx @@ -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 ( +
+

Account name

+ +

Device name

+ + +
+ ); +} diff --git a/src/pages/parts/auth/PassphraseGeneratePart.tsx b/src/pages/parts/auth/PassphraseGeneratePart.tsx new file mode 100644 index 00000000..dcc2c683 --- /dev/null +++ b/src/pages/parts/auth/PassphraseGeneratePart.tsx @@ -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 ( +
+

Remeber the following passphrase:

+

{mnemonic}

+ +
+ ); +} diff --git a/src/pages/parts/auth/TrustBackendPart.tsx b/src/pages/parts/auth/TrustBackendPart.tsx new file mode 100644 index 00000000..1726905d --- /dev/null +++ b/src/pages/parts/auth/TrustBackendPart.tsx @@ -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

loading...

; + + if (result.error || !result.value) + return

Failed to talk to backend, did you configure it correctly?

; + + return ( +
+

+ do you trust{" "} + {result.value.domain} +

+
+

{result.value.data.name}

+ {result.value.data.description ? ( +

{result.value.data.description}

+ ) : null} +
+ +
+ ); +} diff --git a/src/pages/parts/auth/VerifyPassphrasePart.tsx b/src/pages/parts/auth/VerifyPassphrasePart.tsx new file mode 100644 index 00000000..9b5b2222 --- /dev/null +++ b/src/pages/parts/auth/VerifyPassphrasePart.tsx @@ -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 ( +
+

verify passphrase

+ + {result.loading ?

Loading...

: null} + {result.error ?

error: {result.error.toString()}

: null} + +
+ ); +} diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 2bca1e51..e314484a 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -17,6 +17,7 @@ import { DmcaPage } from "@/pages/Dmca"; import { NotFoundPage } from "@/pages/errors/NotFoundPage"; import { HomePage } from "@/pages/HomePage"; import { PlayerView } from "@/pages/PlayerView"; +import { RegisterPage } from "@/pages/Register"; import { SettingsPage } from "@/pages/Settings"; import { Layout } from "@/setup/Layout"; import { useHistoryListener } from "@/stores/history"; @@ -87,6 +88,7 @@ function App() { +