Rename @reviq/utils to @reviq/server-utils and add package READMEs
- Rename packages/utils/ to packages/server-utils/ - Update all imports and package.json references - Add READMEs for frontend-utils, server-utils, and common packages - Update main README with new package structure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
24
packages/server-utils/README.md
Normal file
24
packages/server-utils/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @reviq/server-utils
|
||||
|
||||
Server and CLI utilities for the RevIQ platform. These utilities use crypto APIs and are designed for Cloudflare Workers compatibility.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
generateSecureBase58Token,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
} from "@reviq/server-utils";
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
### Token Generation
|
||||
- `generateSecureBase58Token(prefix)` - Generate a secure random token with a prefix (e.g., `reviq_`)
|
||||
- `parseBase58Token(token)` - Parse and validate a base58-encoded token
|
||||
- `isBase58(str)` - Check if a string is valid base58
|
||||
|
||||
### Password Hashing
|
||||
- `hashPassword(password)` - Hash a password using PBKDF2-SHA256
|
||||
- `verifyPassword(password, hash)` - Verify a password against a stored hash
|
||||
15
packages/server-utils/eslint.config.js
Normal file
15
packages/server-utils/eslint.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { configs } from "@macalinao/eslint-config";
|
||||
|
||||
export default [
|
||||
...configs.fast,
|
||||
{
|
||||
ignores: ["**/*.test.ts"],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
27
packages/server-utils/package.json
Normal file
27
packages/server-utils/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@reviq/server-utils",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||
"lint": "eslint . --cache",
|
||||
"test": "bun test src/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250529.0",
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
105
packages/server-utils/src/generate-base58-token.test.ts
Normal file
105
packages/server-utils/src/generate-base58-token.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
generateSecureBase58Token,
|
||||
isBase58,
|
||||
parseBase58Token,
|
||||
} from "./generate-base58-token.js";
|
||||
|
||||
const BASE58_ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
describe("isBase58", () => {
|
||||
it("should return true for valid base58 strings", () => {
|
||||
expect(isBase58("123456789")).toBe(true);
|
||||
expect(isBase58("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isBase58("9JKmn")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for strings with invalid characters", () => {
|
||||
// 0, O, I, l are not in base58 alphabet
|
||||
expect(isBase58("0")).toBe(false);
|
||||
expect(isBase58("O")).toBe(false);
|
||||
expect(isBase58("I")).toBe(false);
|
||||
expect(isBase58("l")).toBe(false);
|
||||
expect(isBase58("abc0def")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty strings", () => {
|
||||
expect(isBase58("")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for strings with special characters", () => {
|
||||
expect(isBase58("abc+def")).toBe(false);
|
||||
expect(isBase58("abc/def")).toBe(false);
|
||||
expect(isBase58("abc=def")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSecureBase58Token", () => {
|
||||
it("should generate a valid base58 token", () => {
|
||||
const token = generateSecureBase58Token();
|
||||
expect(isBase58(token)).toBe(true);
|
||||
});
|
||||
|
||||
it("should generate tokens of expected length (~33 chars for 24 bytes)", () => {
|
||||
const token = generateSecureBase58Token();
|
||||
// 24 bytes = 192 bits, base58 encoding gives roughly 33 chars
|
||||
expect(token.length).toBeGreaterThanOrEqual(30);
|
||||
expect(token.length).toBeLessThanOrEqual(35);
|
||||
});
|
||||
|
||||
it("should generate unique tokens", () => {
|
||||
const tokens = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.add(generateSecureBase58Token());
|
||||
}
|
||||
expect(tokens.size).toBe(100);
|
||||
});
|
||||
|
||||
it("should only use base58 alphabet characters", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const token = generateSecureBase58Token();
|
||||
for (const char of token) {
|
||||
expect(BASE58_ALPHABET.includes(char)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should prepend prefix when provided", () => {
|
||||
const token = generateSecureBase58Token("lrt_");
|
||||
expect(token.startsWith("lrt_")).toBe(true);
|
||||
// The part after prefix should be valid base58
|
||||
expect(isBase58(token.slice(4))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBase58Token", () => {
|
||||
it("should parse token with correct prefix", () => {
|
||||
const token = generateSecureBase58Token("lrt_");
|
||||
const parsed = parseBase58Token(token, "lrt_");
|
||||
expect(parsed).not.toBeNull();
|
||||
if (parsed !== null) {
|
||||
expect(isBase58(parsed)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null for wrong prefix", () => {
|
||||
const token = generateSecureBase58Token("lrt_");
|
||||
const parsed = parseBase58Token(token, "wrong_");
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for missing prefix", () => {
|
||||
const token = generateSecureBase58Token();
|
||||
const parsed = parseBase58Token(token, "lrt_");
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for invalid base58 after prefix", () => {
|
||||
const invalidToken = "lrt_abc0def"; // 0 is not valid base58
|
||||
const parsed = parseBase58Token(invalidToken, "lrt_");
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
});
|
||||
75
packages/server-utils/src/generate-base58-token.ts
Normal file
75
packages/server-utils/src/generate-base58-token.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Generate cryptographically secure base58 tokens
|
||||
* Uses Bitcoin-style base58 alphabet (no 0, O, I, l to avoid confusion)
|
||||
*/
|
||||
|
||||
const BASE58_ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Check if a string is valid base58
|
||||
*/
|
||||
export const isBase58 = (str: string): boolean => {
|
||||
for (const char of str) {
|
||||
if (!BASE58_ALPHABET.includes(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return str.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure base58 token
|
||||
* Uses 24 bytes (192 bits) of entropy, producing ~33 character output
|
||||
*
|
||||
* @param prefix - Optional prefix to prepend (e.g., "login_" for login request tokens)
|
||||
*/
|
||||
export function generateSecureBase58Token<TPrefix extends string = "">(
|
||||
prefix?: TPrefix,
|
||||
): `${TPrefix}${string}` {
|
||||
const byteLength = 24;
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Convert bytes to base58
|
||||
let result = "";
|
||||
let num = BigInt(0);
|
||||
for (const byte of bytes) {
|
||||
num = num * 256n + BigInt(byte);
|
||||
}
|
||||
|
||||
while (num > 0n) {
|
||||
const remainder = Number(num % 58n);
|
||||
result = BASE58_ALPHABET.charAt(remainder) + result;
|
||||
num /= 58n;
|
||||
}
|
||||
|
||||
// Handle leading zeros
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0) {
|
||||
result = BASE58_ALPHABET.charAt(0) + result;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${prefix ?? ""}${result}` as `${TPrefix}${string}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a token with an expected prefix
|
||||
* Returns the token without prefix if valid, null if invalid
|
||||
*/
|
||||
export const parseBase58Token = (
|
||||
token: string,
|
||||
expectedPrefix: string,
|
||||
): string | null => {
|
||||
if (!token.startsWith(expectedPrefix)) {
|
||||
return null;
|
||||
}
|
||||
const base58Part = token.slice(expectedPrefix.length);
|
||||
if (!isBase58(base58Part)) {
|
||||
return null;
|
||||
}
|
||||
return base58Part;
|
||||
};
|
||||
163
packages/server-utils/src/hash-password.test.ts
Normal file
163
packages/server-utils/src/hash-password.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { hashPassword, verifyPassword } from "./hash-password.js";
|
||||
|
||||
describe("hashPassword", () => {
|
||||
it("should generate a hash in the correct format", async () => {
|
||||
const password = "testPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Format: $pbkdf2-sha256$iterations$salt$hash
|
||||
const parts = hash.split("$");
|
||||
expect(parts.length).toBe(5);
|
||||
expect(parts[0]).toBe("");
|
||||
expect(parts[1]).toBe("pbkdf2-sha256");
|
||||
expect(parts[2]).toBe("100000");
|
||||
// Salt and hash should be non-empty base64 strings
|
||||
expect(parts[3]?.length).toBeGreaterThan(0);
|
||||
expect(parts[4]?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should generate different hashes for the same password", async () => {
|
||||
const password = "testPassword123!";
|
||||
const hash1 = await hashPassword(password);
|
||||
const hash2 = await hashPassword(password);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("should generate different hashes for different passwords", async () => {
|
||||
const hash1 = await hashPassword("password1");
|
||||
const hash2 = await hashPassword("password2");
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("should verify correct password", async () => {
|
||||
const password = "correctPassword!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const result = await verifyPassword(password, hash);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject incorrect password", async () => {
|
||||
const password = "correctPassword!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const result = await verifyPassword("wrongPassword!", hash);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid hash format", async () => {
|
||||
const result = await verifyPassword("password", "invalid-hash");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject hash with wrong algorithm", async () => {
|
||||
const result = await verifyPassword(
|
||||
"password",
|
||||
"$bcrypt$100000$c2FsdA==$aGFzaA==",
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject hash with invalid iterations", async () => {
|
||||
const result = await verifyPassword(
|
||||
"password",
|
||||
"$pbkdf2-sha256$invalid$c2FsdA==$aGFzaA==",
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject hash with zero iterations", async () => {
|
||||
const result = await verifyPassword(
|
||||
"password",
|
||||
"$pbkdf2-sha256$0$c2FsdA==$aGFzaA==",
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty password", async () => {
|
||||
const hash = await hashPassword("");
|
||||
const result = await verifyPassword("", hash);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const wrongResult = await verifyPassword("notempty", hash);
|
||||
expect(wrongResult).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle unicode passwords", async () => {
|
||||
const password = "pässwörd🔐";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const result = await verifyPassword(password, hash);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const wrongResult = await verifyPassword("password", hash);
|
||||
expect(wrongResult).toBe(false);
|
||||
});
|
||||
|
||||
it("should verify known hash for 'password'", async () => {
|
||||
// This hash was generated for the password "password"
|
||||
const storedHash =
|
||||
"$pbkdf2-sha256$100000$iUaDbbVm+Mf0HG7RcCCOzw==$IQfBN4chRU3wqCEoC9XOusIVYkyW24dbJd/ksm0VBJk=";
|
||||
const password = "password";
|
||||
|
||||
const result = await verifyPassword(password, storedHash);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should derive consistent hash for known salt and password", async () => {
|
||||
// Manually verify the derivation produces the expected hash
|
||||
const password = "password";
|
||||
const saltB64 = "iUaDbbVm+Mf0HG7RcCCOzw==";
|
||||
const expectedHashB64 = "IQfBN4chRU3wqCEoC9XOusIVYkyW24dbJd/ksm0VBJk=";
|
||||
const iterations = 100000;
|
||||
|
||||
// Decode salt
|
||||
const saltBinary = atob(saltB64);
|
||||
const salt = new Uint8Array(saltBinary.length);
|
||||
for (let i = 0; i < saltBinary.length; i++) {
|
||||
salt[i] = saltBinary.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Derive key
|
||||
const encoder = new TextEncoder();
|
||||
const passwordBuffer = encoder.encode(password);
|
||||
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBuffer,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
32 * 8,
|
||||
);
|
||||
|
||||
// Encode result to base64
|
||||
const derivedArray = new Uint8Array(derivedBits);
|
||||
let binary = "";
|
||||
for (const byte of derivedArray) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
const derivedB64 = btoa(binary);
|
||||
|
||||
console.log("Expected hash:", expectedHashB64);
|
||||
console.log("Derived hash: ", derivedB64);
|
||||
console.log("Match:", derivedB64 === expectedHashB64);
|
||||
|
||||
expect(derivedB64).toBe(expectedHashB64);
|
||||
});
|
||||
});
|
||||
131
packages/server-utils/src/hash-password.ts
Normal file
131
packages/server-utils/src/hash-password.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Hash a password using PBKDF2 with SHA-256 (Web Crypto API compatible)
|
||||
*
|
||||
* Format: $pbkdf2-sha256$iterations$salt$hash
|
||||
* - iterations: number of PBKDF2 iterations
|
||||
* - salt: base64-encoded 16-byte random salt
|
||||
* - hash: base64-encoded 32-byte derived key
|
||||
*/
|
||||
|
||||
const ITERATIONS = 100000;
|
||||
const SALT_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
const ALGORITHM = "pbkdf2-sha256";
|
||||
|
||||
const toBase64 = (buffer: ArrayBuffer): string => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const fromBase64 = (base64: string): Uint8Array => {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const hashPassword = async (password: string): Promise<string> => {
|
||||
const encoder = new TextEncoder();
|
||||
const passwordBuffer = encoder.encode(password);
|
||||
|
||||
// Generate random salt
|
||||
const salt = new Uint8Array(SALT_LENGTH);
|
||||
crypto.getRandomValues(salt);
|
||||
|
||||
// Import password as key
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBuffer,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
|
||||
// Derive key using PBKDF2
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: ITERATIONS,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
KEY_LENGTH * 8,
|
||||
);
|
||||
|
||||
// Format: $algorithm$iterations$salt$hash
|
||||
const saltB64 = toBase64(salt.buffer);
|
||||
const hashB64 = toBase64(derivedBits);
|
||||
|
||||
return `$${ALGORITHM}$${String(ITERATIONS)}$${saltB64}$${hashB64}`;
|
||||
};
|
||||
|
||||
export const verifyPassword = async (
|
||||
password: string,
|
||||
storedHash: string,
|
||||
): Promise<boolean> => {
|
||||
// Parse stored hash
|
||||
const parts = storedHash.split("$");
|
||||
if (parts.length !== 5 || parts[0] !== "" || parts[1] !== ALGORITHM) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const iterationsStr = parts[2];
|
||||
const saltStr = parts[3];
|
||||
const hashStr = parts[4];
|
||||
if (!(iterationsStr && saltStr && hashStr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const iterations = Number.parseInt(iterationsStr, 10);
|
||||
const salt = fromBase64(saltStr);
|
||||
const expectedHash = fromBase64(hashStr);
|
||||
|
||||
if (Number.isNaN(iterations) || iterations <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const passwordBuffer = encoder.encode(password);
|
||||
|
||||
// Import password as key
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBuffer,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
|
||||
// Derive key using PBKDF2 with same parameters
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
// @ts-expect-error - salt is a Uint8Array
|
||||
salt,
|
||||
iterations,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
expectedHash.length * 8,
|
||||
);
|
||||
|
||||
// Constant-time comparison
|
||||
const derivedArray = new Uint8Array(derivedBits);
|
||||
if (derivedArray.length !== expectedHash.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < derivedArray.length; i++) {
|
||||
result |= (derivedArray[i] ?? 0) ^ (expectedHash[i] ?? 0);
|
||||
}
|
||||
|
||||
return result === 0;
|
||||
};
|
||||
6
packages/server-utils/src/index.ts
Normal file
6
packages/server-utils/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
generateSecureBase58Token,
|
||||
isBase58,
|
||||
parseBase58Token,
|
||||
} from "./generate-base58-token.js";
|
||||
export { hashPassword, verifyPassword } from "./hash-password.js";
|
||||
6
packages/server-utils/tsconfig.json
Normal file
6
packages/server-utils/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user