feat: additional api package stuff

This commit is contained in:
Adrian Castro
2024-04-15 21:18:25 +02:00
parent 4e01f35458
commit 338e633d48
8 changed files with 340 additions and 0 deletions

View File

@@ -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"
}
}

134
packages/api/src/crypto.ts Normal file
View File

@@ -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<Keys> {
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<Uint8Array> {
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<string>((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();
}

View File

@@ -1,2 +1,4 @@
export const name = "api";
export * from "./auth";
export * from "./crypto";

48
packages/api/src/login.ts Normal file
View File

@@ -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<ChallengeTokenResponse> {
return ofetch<ChallengeTokenResponse>("/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<LoginResponse> {
return ofetch<LoginResponse>("/auth/login/complete", {
method: "POST",
body: {
namespace: "movie-web",
...data,
},
baseURL: url,
});
}

15
packages/api/src/meta.ts Normal file
View File

@@ -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<MetaResponse> {
return ofetch<MetaResponse>("/meta", {
baseURL: url,
});
}

View File

@@ -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<SessionResponse[]>(`/users/${account.userId}/sessions`, {
headers: getAuthHeaders(account.token),
baseURL: url,
});
}
export async function updateSession(
url: string,
account: AccountWithToken,
update: SessionUpdate,
) {
return ofetch<SessionResponse[]>(`/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<SessionResponse[]>(`/sessions/${sessionId}`, {
method: "DELETE",
headers: getAuthHeaders(token),
baseURL: url,
});
}

View File

@@ -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<SettingsResponse>(`/users/${account.userId}/settings`, {
method: "PUT",
body: settings,
baseURL: url,
headers: getAuthHeaders(account.token),
});
}
export function getSettings(url: string, account: AccountWithToken) {
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
method: "GET",
baseURL: url,
headers: getAuthHeaders(account.token),
});
}

34
pnpm-lock.yaml generated
View File

@@ -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