diff --git a/server/bun.lock b/server/bun.lock new file mode 100644 index 0000000..5718a59 --- /dev/null +++ b/server/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@cap.js/server", + "dependencies": { + "@types/node": "^22.14.1", + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/server/index.d.ts b/server/index.d.ts new file mode 100644 index 0000000..bd28618 --- /dev/null +++ b/server/index.d.ts @@ -0,0 +1,149 @@ +export = Cap; +/** + * Main Cap class + * @extends EventEmitter + */ +declare class Cap extends EventEmitter<[never]> { + /** + * Creates a new Cap instance + * @param {Partial} [configObj] - Configuration object + */ + constructor(configObj?: Partial); + /** @type {Promise|null} */ + _cleanupPromise: Promise | null; + /** @type {Required} */ + config: Required; + /** + * Generates a new challenge + * @param {ChallengeConfig} [conf] - Challenge configuration + * @returns {{ challenge: Array, token?: string, expires: number }} Challenge data + */ + createChallenge(conf?: ChallengeConfig): { + challenge: Array; + token?: string; + expires: number; + }; + /** + * Redeems a challenge solution in exchange for a token + * @param {Solution} param0 - Challenge solution data + * @returns {Promise<{success: boolean, message?: string, token?: string, expires?: number}>} + */ + redeemChallenge({ token, solutions }: Solution): Promise<{ + success: boolean; + message?: string; + token?: string; + expires?: number; + }>; + /** + * Validates a token + * @param {string} token - The token to validate + * @param {TokenConfig} [conf] - Validation configuration + * @returns {Promise<{success: boolean}>} + */ + validateToken(token: string, conf?: TokenConfig): Promise<{ + success: boolean; + }>; + /** + * Loads tokens from the storage file + * @private + * @returns {Promise} + */ + private _loadTokens; + /** + * Removes expired tokens and challenges from memory + * @private + * @returns {boolean} - True if any tokens were changed/removed + */ + private _cleanExpiredTokens; + /** + * Waits for the tokens list to be initialized + * @private + * @returns {Promise} + */ + private _waitForTokensList; + /** + * Cleans up expired tokens and syncs state + * @returns {Promise} + */ + cleanup(): Promise; +} +declare namespace Cap { + export { Crypto, FsPromises, PathLike, ChallengeTuple, ChallengeData, ChallengeState, ChallengeConfig, TokenConfig, Solution, CapConfig }; +} +import { EventEmitter } from "events"; +type Crypto = typeof import("node:crypto"); +type FsPromises = typeof import("node:fs/promises"); +type PathLike = import("fs").PathLike; +type ChallengeTuple = [string, string]; +type ChallengeData = { + /** + * - Array of [salt, target] tuples + */ + challenge: Array; + /** + * - Expiration timestamp + */ + expires: number; + /** + * - Challenge token + */ + token: string; +}; +type ChallengeState = { + /** + * - Map of challenge tokens to challenge data + */ + challengesList: Record; + /** + * - Map of token hashes to expiration timestamps + */ + tokensList: Record; +}; +type ChallengeConfig = { + /** + * - Number of challenges to generate + */ + challengeCount?: number | undefined; + /** + * - Size of each challenge in bytes + */ + challengeSize?: number | undefined; + /** + * - Difficulty level of the challenge + */ + challengeDifficulty?: number | undefined; + /** + * - Time in milliseconds until the challenge expires + */ + expiresMs?: number | undefined; + /** + * - Whether to store the challenge in memory + */ + store?: boolean | undefined; +}; +type TokenConfig = { + /** + * - Whether to keep the token after validation + */ + keepToken?: boolean | undefined; +}; +type Solution = { + /** + * - The challenge token + */ + token: string; + /** + * - Array of [salt, target, solution] tuples + */ + solutions: Array<[string, string, string]>; +}; +type CapConfig = { + /** + * - Path to store the tokens file + */ + tokens_store_path: string; + /** + * - State configuration + */ + state: ChallengeState; +}; diff --git a/server/index.js b/server/index.js index 44bafa3..219f447 100644 --- a/server/index.js +++ b/server/index.js @@ -1,228 +1,322 @@ +// @ts-check +/// +/// + +/** + * @typedef {import('node:crypto')} Crypto + * @typedef {import('node:fs/promises')} FsPromises + * @typedef {import('fs').PathLike} PathLike + */ + +/** + * @typedef {[string, string]} ChallengeTuple + */ + +/** + * @typedef {Object} ChallengeData + * @property {Array} challenge - Array of [salt, target] tuples + * @property {number} expires - Expiration timestamp + * @property {string} token - Challenge token + */ + +/** + * @typedef {Object} ChallengeState + * @property {Record} challengesList - Map of challenge tokens to challenge data + * @property {Record} tokensList - Map of token hashes to expiration timestamps + */ + +/** + * @typedef {Object} ChallengeConfig + * @property {number} [challengeCount=18] - Number of challenges to generate + * @property {number} [challengeSize=32] - Size of each challenge in bytes + * @property {number} [challengeDifficulty=4] - Difficulty level of the challenge + * @property {number} [expiresMs=600000] - Time in milliseconds until the challenge expires + * @property {boolean} [store=true] - Whether to store the challenge in memory + */ + +/** + * @typedef {Object} TokenConfig + * @property {boolean} [keepToken] - Whether to keep the token after validation + */ + +/** + * @typedef {Object} Solution + * @property {string} token - The challenge token + * @property {Array<[string, string, string]>} solutions - Array of [salt, target, solution] tuples + */ + +/** + * @typedef {Object} CapConfig + * @property {string} tokens_store_path - Path to store the tokens file + * @property {ChallengeState} state - State configuration + */ + +/** @type {typeof import('node:crypto')} */ const crypto = require("crypto"); +/** @type {typeof import('node:fs/promises')} */ const fs = require("fs/promises"); const { EventEmitter } = require("events"); const DEFAULT_TOKENS_STORE = ".data/tokensList.json"; -(function (define) { - define(function (require, exports, module) { - class Cap extends EventEmitter { - constructor(configObj) { - super(); - this._cleanupPromise = null; - this.config = { - tokens_store_path: DEFAULT_TOKENS_STORE, - state: { - challengesList: {}, - tokensList: {}, - }, - ...configObj, - }; +/** + * Main Cap class + * @extends EventEmitter + */ +class Cap extends EventEmitter { + /** @type {Promise|null} */ + _cleanupPromise; - this._loadTokens().catch(() => { }); + /** @type {Required} */ + config; - process.on("beforeExit", () => this.cleanup()); + /** + * Creates a new Cap instance + * @param {Partial} [configObj] - Configuration object + */ + constructor(configObj) { + super(); + this._cleanupPromise = null; + /** @type {Required} */ + this.config = { + tokens_store_path: DEFAULT_TOKENS_STORE, + state: { + challengesList: {}, + tokensList: {}, + }, + ...configObj, + }; - ["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => { - process.once(signal, () => { - this.cleanup() - .then(() => process.exit(0)) - .catch(() => process.exit(1)); - }); - }); - } + this._loadTokens().catch(() => {}); - async _loadTokens() { - try { - const dirPath = this.config.tokens_store_path.split('/').slice(0, -1).join('/'); - if (dirPath) { - await fs.mkdir(dirPath, { recursive: true }); - } + process.on("beforeExit", () => this.cleanup()); - try { - await fs.access(this.config.tokens_store_path); - const data = await fs.readFile( - this.config.tokens_store_path, - "utf-8" - ); - this.config.state.tokensList = JSON.parse(data) || {}; - this._cleanExpiredTokens(); - } catch { - console.log( - `[cap] Tokens file not found, creating a new empty one` - ); - await fs.writeFile(this.config.tokens_store_path, "{}", "utf-8"); - this.config.state.tokensList = {}; - } - } catch (error) { - console.log( - `[cap] Couldn't load or write tokens file, using empty state` - ); - this.config.state.tokensList = {}; - } - } + ["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => { + process.once(signal, () => { + this.cleanup() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); + }); + }); + } - _cleanExpiredTokens() { - const now = Date.now(); - let tokensChanged = false; + /** + * Generates a new challenge + * @param {ChallengeConfig} [conf] - Challenge configuration + * @returns {{ challenge: Array, token?: string, expires: number }} Challenge data + */ + createChallenge(conf) { + this._cleanExpiredTokens(); - for (const k in this.config.state.challengesList) { - if (this.config.state.challengesList[k].expires < now) { - delete this.config.state.challengesList[k]; - } - } + /** @type {Array} */ + const challenges = Array.from( + { length: (conf && conf.challengeCount) || 18 }, + () => + /** @type {ChallengeTuple} */ ([ + crypto + .randomBytes(Math.ceil(((conf && conf.challengeSize) || 32) / 2)) + .toString("hex") + .slice(0, (conf && conf.challengeSize) || 32), + crypto + .randomBytes( + Math.ceil(((conf && conf.challengeDifficulty) || 4) / 2) + ) + .toString("hex") + .slice(0, (conf && conf.challengeDifficulty) || 4), + ]) + ); - for (const k in this.config.state.tokensList) { - if (this.config.state.tokensList[k] < now) { - delete this.config.state.tokensList[k]; - tokensChanged = true; - } - } + const token = crypto.randomBytes(25).toString("hex"); + const expires = Date.now() + ((conf && conf.expiresMs) || 600000); - return tokensChanged; - } + if (conf && conf.store === false) { + return { challenge: challenges, expires }; + } - _waitForTokensList() { - return new Promise((resolve) => { - const l = () => { - if (this.config.state.tokensList) { - return resolve(); - } - setTimeout(l, 10); - }; - l(); - }); - } + this.config.state.challengesList[token] = { + challenge: challenges, + expires, + token, + }; - async cleanup() { - if (this._cleanupPromise) return this._cleanupPromise; + return { challenge: challenges, token, expires }; + } - this._cleanupPromise = (async () => { - const tokensChanged = this._cleanExpiredTokens(); + /** + * Redeems a challenge solution in exchange for a token + * @param {Solution} param0 - Challenge solution data + * @returns {Promise<{success: boolean, message?: string, token?: string, expires?: number}>} + */ + async redeemChallenge({ token, solutions }) { + this._cleanExpiredTokens(); - if (tokensChanged) { - await fs.writeFile( - this.config.tokens_store_path, - JSON.stringify(this.config.state.tokensList), - "utf8" - ); - } - })(); + const challengeData = this.config.state.challengesList[token]; + if (!challengeData || challengeData.expires < Date.now()) { + delete this.config.state.challengesList[token]; + return { success: false, message: "Challenge expired" }; + } - return this._cleanupPromise; - } + delete this.config.state.challengesList[token]; - createChallenge(conf) { - this._cleanExpiredTokens(); + const isValid = challengeData.challenge.every(([salt, target]) => { + const solution = solutions.find(([s, t]) => s === salt && t === target); + return ( + solution && + crypto + .createHash("sha256") + .update(salt + solution[2]) + .digest("hex") + .startsWith(target) + ); + }); - const challenges = Array.from( - { length: (conf && conf.challengeCount) || 18 }, - () => [ - crypto - .randomBytes(Math.ceil(((conf && conf.challengeSize) || 32) / 2)) - .toString("hex") - .slice(0, (conf && conf.challengeSize) || 32), - crypto - .randomBytes( - Math.ceil(((conf && conf.challengeDifficulty) || 4) / 2) - ) - .toString("hex") - .slice(0, (conf && conf.challengeDifficulty) || 4), - ] - ); + if (!isValid) return { success: false, message: "Invalid solution" }; - const token = crypto.randomBytes(25).toString("hex"); - const expires = Date.now() + ((conf && conf.expiresMs) || 600000); + const vertoken = crypto.randomBytes(15).toString("hex"); + const expires = Date.now() + 20 * 60 * 1000; + const hash = crypto.createHash("sha256").update(vertoken).digest("hex"); + const id = crypto.randomBytes(8).toString("hex"); - if (conf && conf.store === false) { - return { challenge: challenges, expires }; - } + this.config.state.tokensList[`${id}:${hash}`] = expires; - this.config.state.challengesList[token] = { - challenge: challenges, - expires, - token, - }; + await fs.writeFile( + this.config.tokens_store_path, + JSON.stringify(this.config.state.tokensList), + "utf8" + ); - return { challenge: challenges, token, expires }; - } + return { success: true, token: `${id}:${vertoken}`, expires }; + } - async redeemChallenge({ token, solutions }) { - this._cleanExpiredTokens(); + /** + * Validates a token + * @param {string} token - The token to validate + * @param {TokenConfig} [conf] - Validation configuration + * @returns {Promise<{success: boolean}>} + */ + async validateToken(token, conf) { + this._cleanExpiredTokens(); - const challengeData = this.config.state.challengesList[token]; - if (!challengeData || challengeData.expires < Date.now()) { - delete this.config.state.challengesList[token]; - return { success: false, message: "Challenge expired" }; - } + const [id, vertoken] = token.split(":"); + const hash = crypto.createHash("sha256").update(vertoken).digest("hex"); + const key = `${id}:${hash}`; - delete this.config.state.challengesList[token]; + await this._waitForTokensList(); - const isValid = challengeData.challenge.every(([salt, target]) => { - const solution = solutions.find( - ([s, t]) => s === salt && t === target - ); - return ( - solution && - crypto - .createHash("sha256") - .update(salt + solution[2]) - .digest("hex") - .startsWith(target) - ); - }); - - if (!isValid) return { success: false, message: "Invalid solution" }; - - const vertoken = crypto.randomBytes(15).toString("hex"); - const expires = Date.now() + 20 * 60 * 1000; - const hash = crypto.createHash("sha256").update(vertoken).digest("hex"); - const id = crypto.randomBytes(8).toString("hex"); - - this.config.state.tokensList[`${id}:${hash}`] = expires; + if (this.config.state.tokensList[key]) { + if (conf && conf.keepToken) { + delete this.config.state.tokensList[key]; await fs.writeFile( this.config.tokens_store_path, JSON.stringify(this.config.state.tokensList), "utf8" ); - - return { success: true, token: `${id}:${vertoken}`, expires }; } - async validateToken(token, conf) { + return { success: true }; + } + + return { success: false }; + } + + /** + * Loads tokens from the storage file + * @private + * @returns {Promise} + */ + async _loadTokens() { + try { + const dirPath = this.config.tokens_store_path + .split("/") + .slice(0, -1) + .join("/"); + if (dirPath) { + await fs.mkdir(dirPath, { recursive: true }); + } + + try { + await fs.access(this.config.tokens_store_path); + const data = await fs.readFile(this.config.tokens_store_path, "utf-8"); + this.config.state.tokensList = JSON.parse(data) || {}; this._cleanExpiredTokens(); + } catch { + console.log(`[cap] Tokens file not found, creating a new empty one`); + await fs.writeFile(this.config.tokens_store_path, "{}", "utf-8"); + this.config.state.tokensList = {}; + } + } catch (error) { + console.log( + `[cap] Couldn't load or write tokens file, using empty state` + ); + this.config.state.tokensList = {}; + } + } - const [id, vertoken] = token.split(":"); - const hash = crypto.createHash("sha256").update(vertoken).digest("hex"); - const key = `${id}:${hash}`; + /** + * Removes expired tokens and challenges from memory + * @private + * @returns {boolean} - True if any tokens were changed/removed + */ + _cleanExpiredTokens() { + const now = Date.now(); + let tokensChanged = false; - await this._waitForTokensList(); - - if (this.config.state.tokensList[key]) { - if (conf && conf.keepToken) { - delete this.config.state.tokensList[key]; - - await fs.writeFile( - this.config.tokens_store_path, - JSON.stringify(this.config.state.tokensList), - "utf8" - ); - } - - return { success: true }; - } - - return { success: false }; + for (const k in this.config.state.challengesList) { + if (this.config.state.challengesList[k].expires < now) { + delete this.config.state.challengesList[k]; } } - return Cap; - }); -})( - typeof module === "object" && module.exports && typeof define !== "function" - ? function (factory) { - module.exports = factory(require, exports, module); + for (const k in this.config.state.tokensList) { + if (this.config.state.tokensList[k] < now) { + delete this.config.state.tokensList[k]; + tokensChanged = true; + } } - : define -); + + return tokensChanged; + } + + /** + * Waits for the tokens list to be initialized + * @private + * @returns {Promise} + */ + _waitForTokensList() { + return new Promise((resolve) => { + const l = () => { + if (this.config.state.tokensList) { + return resolve(); + } + setTimeout(l, 10); + }; + l(); + }); + } + + /** + * Cleans up expired tokens and syncs state + * @returns {Promise} + */ + async cleanup() { + if (this._cleanupPromise) return this._cleanupPromise; + + this._cleanupPromise = (async () => { + const tokensChanged = this._cleanExpiredTokens(); + + if (tokensChanged) { + await fs.writeFile( + this.config.tokens_store_path, + JSON.stringify(this.config.state.tokensList), + "utf8" + ); + } + })(); + + return this._cleanupPromise; + } +} + +/** @type {typeof Cap} */ +module.exports = Cap; diff --git a/server/package.json b/server/package.json index b543e36..a8672e5 100644 --- a/server/package.json +++ b/server/package.json @@ -1,12 +1,13 @@ { "name": "@cap.js/server", - "version": "1.0.8", - "author": "Tiago Rangel", + "version": "1.0.9", + "author": "Tiago", "repository": { "type": "git", "url": "git+https://github.com/tiagorangel1/cap.git" }, "main": "index.js", + "types": "index.d.ts", "bugs": { "url": "https://github.com/tiagorangel1/cap/issues" }, @@ -227,5 +228,9 @@ "test": "echo \"Error: no test specified\" && exit 1", "npm:publish": "sudo npm publish --access public" }, - "type": "commonjs" + "type": "commonjs", + "dependencies": { + "@types/node": "^22.14.1", + "typescript": "^5.8.3" + } } diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..32fe052 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "checkJs": true, + "allowJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "types": ["node"], + "outDir": "." + }, + "include": ["./index.js"], + "exclude": [] +} diff --git a/standalone/public/index.html b/standalone/public/index.html index 7444cef..3e856a5 100644 --- a/standalone/public/index.html +++ b/standalone/public/index.html @@ -77,8 +77,8 @@ padding: 6px 10px; border-radius: 6px; border: none; - color: black; - background: white; + color: white; + background: #0076D8; background-origin: border-box; user-select: none; -webkit-user-select: none; @@ -90,9 +90,9 @@ } button.btn:focus { - box-shadow: inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2), + box-shadow: inset 0px 0.8px 0px -0.25px rgba(0, 118, 216, 0.2), 0px 0.5px 1.5px rgba(54, 122, 246, 0.25), - 0px 0px 0px 3.5px rgba(255, 255, 255, 0.5); + 0px 0px 0px 3.5px rgba(0, 118, 216, 0.5); outline: 0; } @@ -188,13 +188,14 @@ border-radius: 8px; border: none; padding: 10px 14px; - background-color: #2a2a31; + border: 1px solid rgba(255, 255, 255, 0.17); + background-color: transparent; color: inherit; } .key-creation-form input:focus { outline: none; - box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.5); + box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.2); } .key-creation-form input:disabled { diff --git a/standalone/public/index.js b/standalone/public/index.js index d974066..d7520d6 100644 --- a/standalone/public/index.js +++ b/standalone/public/index.js @@ -116,10 +116,10 @@ createKeyBtn.addEventListener("click", async () => {

Make sure to copy your secret key — it's required to validate tokens and you won't be able to see it again.

- +
- +
`; @@ -314,10 +314,10 @@ const reloadKeysList = async () => {

Make sure to copy your new secret key — it's required to validate tokens and you won't be able to see it again.

- +
- +
`; diff --git a/standalone/public/lock.html b/standalone/public/lock.html index 091c32d..a7d2aa7 100644 --- a/standalone/public/lock.html +++ b/standalone/public/lock.html @@ -162,7 +162,7 @@ placeholder="••••••••••••••" class="password" /> -