Skip to content

Commit

Permalink
feat: removed keytar due to arm64 lib issues, new encrypted store add…
Browse files Browse the repository at this point in the history
…ed for keytar replacement
  • Loading branch information
Venipa committed Nov 10, 2024
1 parent cc9faa2 commit 442aefa
Show file tree
Hide file tree
Showing 8 changed files with 240 additions and 170 deletions.
2 changes: 1 addition & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const resolveOptions: UserConfigExport = {
},
},
};
const externalizedEsmDeps = ["lodash-es", "@faker-js/faker", "@trpc-limiter/memory", "got"];
const externalizedEsmDeps = ["lodash-es", "@faker-js/faker", "@trpc-limiter/memory", "got", "encryption.js"];
export default defineConfig({
main: {
...resolveOptions,
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ytmdesktop2",
"version": "0.14.0",
"version": "0.14.1",
"private": false,
"author": "Venipa <[email protected]>",
"main": "./out/main/index.js",
Expand Down Expand Up @@ -35,17 +35,18 @@
"chalk": "^5.0.1",
"core-js": "^3.24.1",
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
"daisyui": "^2.20.0",
"date-fns": "^2.29.1",
"dompurify": "^3.1.7",
"electron-conf": "^1.2.1",
"electron-threads": "^1.0.2",
"electron-updater": "^6.3.9",
"encryption.js": "^1.0.6",
"events": "^3.3.0",
"express": "^4.18.1",
"express-ws": "^5.0.2",
"got": "^11.8.6",
"keytar": "^7.9.0",
"lodash-es": "^4.17.21",
"lucide-vue-next": "^0.454.0",
"node-fetch": "^2.6.7",
Expand All @@ -72,6 +73,7 @@
"@electron-toolkit/tsconfig": "^1.0.1",
"@rushstack/eslint-patch": "^1.10.4",
"@types/cors": "^2.8.17",
"@types/crypto-js": "^4.2.2",
"@types/electron-devtools-installer": "^2.2.0",
"@types/events": "^3.0.0",
"@types/express": "^4.17.13",
Expand Down
54 changes: 54 additions & 0 deletions src/main/lib/secureStore/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { createEncryptedStore } from "../store/createYmlStore";
type Credential = {
account: string;
password: string;
};
type Credentials = Array<Credential>;
type CredentialStore = {
credentials: Record<string, any | null | undefined>;
};
const store = createEncryptedStore<CredentialStore>("credentials", {
defaults: { credentials: {} },
});
class SecureStore {
getAll() {
return new Promise<Credentials>((resolve, reject) =>
resolve(
Object.entries(store.get("credentials", {})).map(
([account, password]) =>
({
account,
password,
}) as Credential,
),
),
);
}
set(key: string, value: string) {
return new Promise<string | null>(async (resolve, reject) => {
store.set({
credentials: {
[key]: value,
},
});
return resolve(value);
});
}
get<T = any>(key: string) {
return new Promise<T | null>(async (resolve, reject) => {
const value = store.get(`credentials.${key}`, null);
return resolve(value);
});
}
delete(key: string) {
return new Promise<boolean>(async (resolve, reject) => {
store.delete(`credentials.${key}`);
return resolve(true);
});
}
readonly setPassword: typeof this.set = this.set.bind(this);
readonly getPassword: typeof this.get = this.get.bind(this);
}

const secureStore = new SecureStore();
export default secureStore;
37 changes: 33 additions & 4 deletions src/main/lib/store/createYmlStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { SlugifyOptions } from "@shared/slug";
import slugify, { SlugifyOptions } from "@shared/slug";
import { base64 } from "@shared/utils/base64";
import { generateRandom } from "@shared/utils/randomString";
import { app } from "electron";
import { ConfOptions as Options, Conf as Store } from "electron-conf/main";
import { mkdirSync, statSync } from "node:fs";
import Encryption from "encryption.js";
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parse as deserialize, stringify as serialize } from "yaml";
const slugifyOptions = {
Expand All @@ -10,8 +13,7 @@ const slugifyOptions = {
trim: true,
remove: /[*+~.()'"!:@]/g,
} as SlugifyOptions;
const getStoreUserData = () =>
import.meta.env.PROD ? app.getPath("userData") : path.join("out/store");
const getStoreUserData = () => app.getPath("userData");
if (!statSync(getStoreUserData(), { throwIfNoEntry: false }))
mkdirSync(getStoreUserData(), { recursive: true });
export const createYmlStore = <T extends Record<string, any> = Record<string, any>>(
Expand All @@ -31,3 +33,30 @@ export const createYmlStore = <T extends Record<string, any> = Record<string, an
},
name,
});

export const createEncryptedStore = <T extends Record<string, any> = Record<string, any>>(
name: string,
options: Options<T> = {} as Options<T>,
) => {
const encryptionKeyPath = path.join(getStoreUserData(), slugify(name, slugifyOptions) + ".key");
const enc = new Encryption({ secret: base64.encode(name) });
if (!existsSync(encryptionKeyPath)) writeFileSync(encryptionKeyPath, enc.encrypt({ name, secret: generateRandom(32) }));
const encryptionKey = readFileSync(encryptionKeyPath).toString("utf8");
const payload = enc.decrypt<{ name: string; secret: string }>(encryptionKey);
if (!payload || name !== payload?.name) throw new Error("Invalid encryption key");
if (!payload.secret) throw new Error("Invalid encryption secret");
const storeEncryptor = new Encryption({ secret: payload.secret });
return new Store<T>({
ext: ".ytm",
...options,
serializer: {
read(raw) {
return storeEncryptor.decrypt(raw);
},
write(value) {
return storeEncryptor.encrypt(value);
},
},
name,
});
};
24 changes: 12 additions & 12 deletions src/main/plugins/lastfmProvider.plugin.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import secureStore from "@main/lib/secureStore";
import { AfterInit, BaseProvider, OnInit } from "@main/utils/baseProvider";
import { IpcContext, IpcHandle } from "@main/utils/onIpcEvent";
import { string } from "@poppinss/utils/build/helpers";
import { App, BrowserWindow, shell } from "electron";
import keytar from "keytar";
import { LastFMSettings } from "ytmd";
import { parseJson, stringifyJson } from "../lib/json";
import { APP_KEYTAR, LASTFM_KEYTAR_SESSION, LASTFM_KEYTAR_TOKEN } from "../lib/keytar";
import { LASTFM_KEYTAR_SESSION, LASTFM_KEYTAR_TOKEN } from "../lib/keytar";
import { LastFMClient } from "../lib/lastfm";
import IPC_EVENT_NAMES from "../utils/eventNames";
import { TrackData } from "../utils/trackData";
Expand Down Expand Up @@ -43,7 +43,7 @@ export default class LastFMProvider extends BaseProvider implements AfterInit, O
}
const lastfm = this.getProvider("settings").get("lastfm") as LastFMSettings;
if (lastfm.enabled) {
const creds = await keytar.findCredentials(APP_KEYTAR);
const creds = await secureStore.getAll();
const lastFMState = creds.reduce(
(acc, r) => {
if (r.account === LASTFM_KEYTAR_TOKEN) acc.token = r.password;
Expand Down Expand Up @@ -97,17 +97,17 @@ export default class LastFMProvider extends BaseProvider implements AfterInit, O
win.webContents.on("did-navigate", async (ev, url, code, status) => {
this.logger.debug(`[URL]> ${url}, ${code}, ${status}`);
if (await hasSuccessInfo()) {
const userState = await win.webContents
const {userState}: LastFMUserState = await win.webContents
.executeJavaScript(`document.getElementById("tlmdata")?.dataset?.tealiumData`)
.then(parseJson<LastFMUserState>)
.catch(() => null);
.catch(() => ({} as any));
this.logger.debug(`[Auth]> User: ${stringifyJson(userState)}`);
if (userState) {
await keytar.setPassword(APP_KEYTAR, LASTFM_KEYTAR_TOKEN, token);
const sessionToken = await this.client.getSession().catch(() => null);
if (userState === "authenticated") {
await secureStore.set(LASTFM_KEYTAR_TOKEN, token);
const sessionToken = await this.client.getSession()
if (sessionToken) {
await keytar.setPassword(APP_KEYTAR, LASTFM_KEYTAR_SESSION, sessionToken);
if (win.isEnabled()) win.close();
await secureStore.set(LASTFM_KEYTAR_SESSION, sessionToken);
if (!win.isDestroyed()) win.close();
}

this.logger.debug(`[Auth]> Authenticated: ${sessionToken}`);
Expand Down Expand Up @@ -173,8 +173,8 @@ export default class LastFMProvider extends BaseProvider implements AfterInit, O
this.client.setAuthorize({ token: null, session: null });
settings.set("lastfm.name", null);
await Promise.all([
keytar.deletePassword(APP_KEYTAR, LASTFM_KEYTAR_SESSION).catch(() => null),
keytar.deletePassword(APP_KEYTAR, LASTFM_KEYTAR_TOKEN).catch(() => null),
secureStore.delete(LASTFM_KEYTAR_SESSION),
secureStore.delete(LASTFM_KEYTAR_TOKEN)
]);
}
this.sendState();
Expand Down
84 changes: 84 additions & 0 deletions src/shared/utils/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Buffer } from 'buffer';

/*
* @poppinss/utils
*
* (c) Poppinss
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

/**
* Helper class to base64 encode/decode values with option
* for url encoding and decoding
*/
class Base64 {
/**
* Base64 encode Buffer or string
*/
encode(arrayBuffer: ArrayBuffer | SharedArrayBuffer): string;
encode(data: string, encoding?: BufferEncoding): string;
encode(data: ArrayBuffer | SharedArrayBuffer | string, encoding?: BufferEncoding): string {
if (typeof data === 'string') {
return Buffer.from(data, encoding).toString('base64');
}
return Buffer.from(data).toString('base64');
}

/**
* Base64 decode a previously encoded string or Buffer.
*/
decode(encode: string, encoding: BufferEncoding, strict: true): string;
decode(encode: string, encoding: undefined, strict: true): string;
decode(encode: string, encoding?: BufferEncoding, strict?: false): string | null;
decode(encode: Buffer, encoding?: BufferEncoding): string;
decode(encoded: string | Buffer, encoding: BufferEncoding = 'utf8', strict: boolean = false): string | null {
if (Buffer.isBuffer(encoded)) {
return encoded.toString(encoding);
}

const decoded = Buffer.from(encoded, 'base64').toString(encoding);
const isInvalid = this.encode(decoded, encoding) !== encoded;

if (strict && isInvalid) {
throw new Error('Cannot decode malformed value');
}

return isInvalid ? null : decoded;
}

/**
* Base64 encode Buffer or string to be URL safe. (RFC 4648)
*/
urlEncode(arrayBuffer: ArrayBuffer | SharedArrayBuffer): string;
urlEncode(data: string, encoding?: BufferEncoding): string;
urlEncode(data: ArrayBuffer | SharedArrayBuffer | string, encoding?: BufferEncoding): string {
const encoded = typeof data === 'string' ? this.encode(data, encoding) : this.encode(data);
return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=/g, '');
}

/**
* Base64 URL decode a previously encoded string or Buffer. (RFC 4648)
*/
urlDecode(encode: string, encoding: BufferEncoding, strict: true): string;
urlDecode(encode: string, encoding: undefined, strict: true): string;
urlDecode(encode: string, encoding?: BufferEncoding, strict?: false): string | null;
urlDecode(encode: Buffer, encoding?: BufferEncoding): string;
urlDecode(encoded: string | Buffer, encoding: BufferEncoding = 'utf8', strict: boolean = false): string | null {
if (Buffer.isBuffer(encoded)) {
return encoded.toString(encoding);
}

const decoded = Buffer.from(encoded, 'base64').toString(encoding);
const isInvalid = this.urlEncode(decoded, encoding) !== encoded;

if (strict && isInvalid) {
throw new Error('Cannot urlDecode malformed value');
}

return isInvalid ? null : decoded;
}
}

export const base64 = new Base64();
11 changes: 11 additions & 0 deletions src/shared/utils/randomString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Buffer } from 'buffer';
import { enc, lib } from 'crypto-js';
import { base64 } from './base64';
/**
* Generates a random string of a given size
*/
export function generateRandom(size: number): string {
const bits = (size + 1) * 6;
const buffer = Buffer.from(lib.WordArray.random(Math.ceil(bits / 8)).toString(enc.Hex), 'hex');
return base64.urlEncode(buffer).slice(0, size);
}
Loading

0 comments on commit 442aefa

Please sign in to comment.