Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions src/lib/auth.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import config from "./config";
import { maskCredential, unmaskCredential } from "../security/security";

/**
* @typedef {object} User
Expand Down Expand Up @@ -43,6 +44,54 @@ const loginEvents = {
},
};

/**
* Clones the user object structure and masks sensitive string properties
* to protect them in memory.
*/
function secureUserObject(user) {
if (!user) return null;

const secured = { ...user };

// Array with String Properties of Object
const textProperties = [
"name", "role", "email", "github", "website",
"avatar_url", "pro_purchased_at", "created_at", "updated_at"
];

// Apply the Mask
textProperties.forEach(prop => {
if (typeof secured[prop] === "string") {
secured[prop] = maskCredential(secured[prop]);
}
});

return secured;
}



/** Function to unmask the user object properties
* so the interface can read them safely without crashing.
*/
export function getDecryptedUser(user) {
if (!user) return null;
const decrypted = { ...user };

const textProperties = [
"name", "role", "email", "github", "website",
"avatar_url", "pro_purchased_at", "created_at", "updated_at"
];

textProperties.forEach(prop => {
if (Array.isArray(decrypted[prop])) {
decrypted[prop] = unmaskCredential(decrypted[prop]);
}
});

return decrypted;
}

class AuthService {
#loginCallbacks = new Set();
#loginTimeout = null;
Expand Down Expand Up @@ -105,17 +154,18 @@ class AuthService {
* @returns {Promise<User>}
*/
async getLoggedInUser(forceFetch = false) {
if (loggedInUser && !forceFetch) return loggedInUser;
if (loggedInUser && !forceFetch) return getDecryptedUser(loggedInUser);

try {
const res = await fetch(`${config.API_BASE}/login`);

if (res.ok) {
loggedInUser = await res.json();
localStorage.setItem(CACHE_USER_KEY, JSON.stringify(loggedInUser));
clearTimeout(cacheTimeout);
cacheTimeout = setTimeout(() => (loggedInUser = null), 600_000);
return loggedInUser;
const rawuser = await res.json();
loggedInUser = secureUserObject(rawuser);
localStorage.setItem(CACHE_USER_KEY, JSON.stringify(loggedInUser));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cached user remains masked

When /login fails after a user has been cached, the fallback returns the stored user without decrypting its string fields. Those fields are now arrays, so the sidebar can throw when it calls name.split(" ") instead of displaying the cached user. Decrypt the parsed cache before returning it.

clearTimeout(cacheTimeout);
cacheTimeout = setTimeout(() => (loggedInUser = null), 600_000);
return getDecryptedUser(loggedInUser);
}

if (res.status === 401) {
Expand Down
27 changes: 27 additions & 0 deletions src/security/security.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Copyright (C) dev12124 (dev brazilian, João Guilherme da Silva Freitas Lima),
* License: MIT license.
*/

/**
* Mask key used to obfuscate sensitive Acode credentials.
* In JavaScript, plain text variables reside unprotected in the RAM,
* making them vulnerable to access or modification by malicious plugins.
*/
const MASK_KEY = 0x5A;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Fixed mask cannot protect credentials

This fixed XOR key does not protect the cached user fields from a malicious plugin. Plugins run in the app document, so one can read the masked user from the same localStorage and reverse the key. The practical cost is that this adds masking without providing the claimed protection; that requires limiting plugin access to the data or isolating plugin execution.

How this was verified: Plugin scripts execute in the app document and can read the same localStorage entry that holds user fields encoded with the fixed XOR key.


// Applies a XOR mask to secure sensitive strings
export function maskCredential(secretString) {
if (!secretString) return [];

return Array.from(secretString).map(char => char.charCodeAt(0) ^ MASK_KEY);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unicode characters are corrupted

For a name containing an emoji or another character outside the basic Unicode range, Array.from keeps the character together but charCodeAt(0) saves only its first UTF-16 unit. Unmasking cannot restore the missing unit, so the user's name is permanently changed. Mask each UTF-16 unit so fromCharCode can reconstruct the original string.

Suggested change
return Array.from(secretString).map(char => char.charCodeAt(0) ^ MASK_KEY);
return secretString.split("").map(char => char.charCodeAt(0) ^ MASK_KEY);

}

// Removes the XOR mask to restore the original string
export function unmaskCredential(maskedArray) {
if (!Array.isArray(maskedArray)) return " ";

return maskedArray
.map(byte => String.fromCharCode(byte ^ MASK_KEY))
.join("");
}
Loading