diff --git a/packages/api/package.json b/packages/api/package.json index 55cdfd0..075d0c6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -18,6 +18,7 @@ "@movie-web/eslint-config": "workspace:^0.2.0", "@movie-web/prettier-config": "workspace:^0.1.0", "@movie-web/tsconfig": "workspace:^0.1.0", + "@types/node-forge": "^1.3.11", "eslint": "^8.56.0", "prettier": "^3.1.1", "typescript": "^5.4.3" @@ -29,6 +30,9 @@ }, "prettier": "@movie-web/prettier-config", "dependencies": { + "@noble/hashes": "^1.4.0", + "@scure/bip39": "^1.3.0", + "node-forge": "^1.3.1", "ofetch": "^1.3.4" } } diff --git a/packages/api/src/crypto.ts b/packages/api/src/crypto.ts new file mode 100644 index 0000000..45d6b41 --- /dev/null +++ b/packages/api/src/crypto.ts @@ -0,0 +1,134 @@ +import { pbkdf2Async } from "@noble/hashes/pbkdf2"; +import { sha256 } from "@noble/hashes/sha256"; +import { generateMnemonic, validateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english"; +import forge from "node-forge"; + +interface Keys { + privateKey: Uint8Array; + publicKey: Uint8Array; + seed: Uint8Array; +} + +async function seedFromMnemonic(mnemonic: string) { + return pbkdf2Async(sha256, mnemonic, "mnemonic", { + c: 2048, + dkLen: 32, + }); +} + +export function verifyValidMnemonic(mnemonic: string) { + return validateMnemonic(mnemonic, wordlist); +} + +export async function keysFromMnemonic(mnemonic: string): Promise { + const seed = await seedFromMnemonic(mnemonic); + + const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({ + seed, + }); + + return { + privateKey, + publicKey, + seed, + }; +} + +export function genMnemonic(): string { + return generateMnemonic(wordlist); +} + +// eslint-disable-next-line @typescript-eslint/require-await +export async function signCode( + code: string, + privateKey: Uint8Array, +): Promise { + return forge.pki.ed25519.sign({ + encoding: "utf8", + message: code, + privateKey, + }); +} + +export function bytesToBase64(bytes: Uint8Array) { + return forge.util.encode64(String.fromCodePoint(...bytes)); +} + +export function bytesToBase64Url(bytes: Uint8Array): string { + return bytesToBase64(bytes) + .replace(/\//g, "_") + .replace(/\+/g, "-") + .replace(/=+$/, ""); +} + +export async function signChallenge(keys: Keys, challengeCode: string) { + const signature = await signCode(challengeCode, keys.privateKey); + return bytesToBase64Url(signature); +} + +export function base64ToBuffer(data: string) { + return forge.util.binary.base64.decode(data); +} + +export function base64ToStringBuffer(data: string) { + return forge.util.createBuffer(base64ToBuffer(data)); +} + +export function stringBufferToBase64(buffer: forge.util.ByteStringBuffer) { + return forge.util.encode64(buffer.getBytes()); +} + +export async function encryptData(data: string, secret: Uint8Array) { + if (secret.byteLength !== 32) + throw new Error("Secret must be at least 256-bit"); + + const iv = await new Promise((resolve, reject) => { + forge.random.getBytes(16, (err, bytes) => { + if (err) reject(err); + resolve(bytes); + }); + }); + + const cipher = forge.cipher.createCipher( + "AES-GCM", + forge.util.createBuffer(secret), + ); + cipher.start({ + iv, + tagLength: 128, + }); + cipher.update(forge.util.createBuffer(data, "utf8")); + cipher.finish(); + + const encryptedData = cipher.output; + const tag = cipher.mode.tag; + + return `${forge.util.encode64(iv)}.${stringBufferToBase64( + encryptedData, + )}.${stringBufferToBase64(tag)}` as const; +} + +export function decryptData(data: string, secret: Uint8Array) { + if (secret.byteLength !== 32) throw new Error("Secret must be 256-bit"); + + const [iv, encryptedData, tag] = data.split("."); + if (!iv || !encryptedData || !tag) + throw new Error("Invalid encrypted data format"); + + const decipher = forge.cipher.createDecipher( + "AES-GCM", + forge.util.createBuffer(secret), + ); + decipher.start({ + iv: base64ToStringBuffer(iv), + tag: base64ToStringBuffer(tag), + tagLength: 128, + }); + decipher.update(base64ToStringBuffer(encryptedData)); + const pass = decipher.finish(); + + if (!pass) throw new Error("Error decrypting data"); + + return decipher.output.toString(); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 726b40a..3e023f7 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,2 +1,4 @@ export const name = "api"; export * from "./auth"; +export * from "./crypto"; + diff --git a/packages/api/src/login.ts b/packages/api/src/login.ts new file mode 100644 index 0000000..14d6fc4 --- /dev/null +++ b/packages/api/src/login.ts @@ -0,0 +1,48 @@ +import { ofetch } from "ofetch"; + +import type { SessionResponse } from "./auth"; + +export interface ChallengeTokenResponse { + challenge: string; +} + +export async function getLoginChallengeToken( + url: string, + publicKey: string, +): Promise { + return ofetch("/auth/login/start", { + method: "POST", + body: { + publicKey, + }, + baseURL: url, + }); +} + +export interface LoginResponse { + session: SessionResponse; + token: string; +} + +export interface LoginInput { + publicKey: string; + challenge: { + code: string; + signature: string; + }; + device: string; +} + +export async function loginAccount( + url: string, + data: LoginInput, +): Promise { + return ofetch("/auth/login/complete", { + method: "POST", + body: { + namespace: "movie-web", + ...data, + }, + baseURL: url, + }); +} diff --git a/packages/api/src/meta.ts b/packages/api/src/meta.ts new file mode 100644 index 0000000..0886dc1 --- /dev/null +++ b/packages/api/src/meta.ts @@ -0,0 +1,15 @@ +import { ofetch } from "ofetch"; + +export interface MetaResponse { + version: string; + name: string; + description?: string; + hasCaptcha: boolean; + captchaClientKey?: string; +} + +export async function getBackendMeta(url: string): Promise { + return ofetch("/meta", { + baseURL: url, + }); +} diff --git a/packages/api/src/sessions.ts b/packages/api/src/sessions.ts new file mode 100644 index 0000000..c3869b4 --- /dev/null +++ b/packages/api/src/sessions.ts @@ -0,0 +1,64 @@ +import { ofetch } from "ofetch"; + +import { getAuthHeaders } from "./auth"; + +export interface SessionResponse { + id: string; + userId: string; + createdAt: string; + accessedAt: string; + device: string; + userAgent: string; +} + +export interface SessionUpdate { + deviceName: string; +} + +interface Account { + profile: { + colorA: string; + colorB: string; + icon: string; + }; +} + +export type AccountWithToken = Account & { + sessionId: string; + userId: string; + token: string; + seed: string; + deviceName: string; +}; + +export async function getSessions(url: string, account: AccountWithToken) { + return ofetch(`/users/${account.userId}/sessions`, { + headers: getAuthHeaders(account.token), + baseURL: url, + }); +} + +export async function updateSession( + url: string, + account: AccountWithToken, + update: SessionUpdate, +) { + return ofetch(`/sessions/${account.sessionId}`, { + method: "PATCH", + headers: getAuthHeaders(account.token), + body: update, + baseURL: url, + }); +} + +export async function removeSession( + url: string, + token: string, + sessionId: string, +) { + return ofetch(`/sessions/${sessionId}`, { + method: "DELETE", + headers: getAuthHeaders(token), + baseURL: url, + }); +} diff --git a/packages/api/src/settings.ts b/packages/api/src/settings.ts new file mode 100644 index 0000000..669a80d --- /dev/null +++ b/packages/api/src/settings.ts @@ -0,0 +1,39 @@ +import { ofetch } from "ofetch"; + +import type { AccountWithToken } from "./sessions"; +import { getAuthHeaders } from "./auth"; + +export interface SettingsInput { + applicationLanguage?: string; + applicationTheme?: string | null; + defaultSubtitleLanguage?: string; + proxyUrls?: string[] | null; +} + +export interface SettingsResponse { + applicationTheme?: string | null; + applicationLanguage?: string | null; + defaultSubtitleLanguage?: string | null; + proxyUrls?: string[] | null; +} + +export function updateSettings( + url: string, + account: AccountWithToken, + settings: SettingsInput, +) { + return ofetch(`/users/${account.userId}/settings`, { + method: "PUT", + body: settings, + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} + +export function getSettings(url: string, account: AccountWithToken) { + return ofetch(`/users/${account.userId}/settings`, { + method: "GET", + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5845370..b131b8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,6 +243,15 @@ importers: packages/api: dependencies: + '@noble/hashes': + specifier: ^1.4.0 + version: 1.4.0 + '@scure/bip39': + specifier: ^1.3.0 + version: 1.3.0 + node-forge: + specifier: ^1.3.1 + version: 1.3.1 ofetch: specifier: ^1.3.4 version: 1.3.4 @@ -256,6 +265,9 @@ importers: '@movie-web/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript + '@types/node-forge': + specifier: ^1.3.11 + version: 1.3.11 eslint: specifier: ^8.56.0 version: 8.56.0 @@ -2905,6 +2917,11 @@ packages: unpacker: 1.0.1 dev: false + /@noble/hashes@1.4.0: + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3592,6 +3609,17 @@ packages: react-native-video: 5.2.1 dev: false + /@scure/base@1.1.6: + resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==} + dev: false + + /@scure/bip39@1.3.0: + resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} + dependencies: + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.6 + dev: false + /@segment/loosely-validate-event@2.0.0: resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==} dependencies: @@ -5260,6 +5288,12 @@ packages: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} dev: true + /@types/node-forge@1.3.11: + resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==} + dependencies: + '@types/node': 20.12.7 + dev: true + /@types/node@17.0.45: resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} dev: false