Add utils package with Web Crypto password hashing

- Create @reviq/utils package with PBKDF2-SHA256 password hashing
  compatible with Cloudflare Workers (uses crypto.subtle)
- Update api-server and CLI to use new utils package for consistent
  password hashing format across the codebase
- Add pino logging to api-server for better request debugging
- Make login request tokens cryptographically secure base58 strings
  instead of database IDs
- Add migration to make login_requests.token non-nullable with unique
  constraint
- Fix RPCLink URL construction for client-side API calls
- Add db:codegen script to root package.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 18:12:33 +08:00
parent cee700f063
commit c1afc39062
25 changed files with 512 additions and 142 deletions

View File

@@ -10,7 +10,11 @@ export type Generated<T> =
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string>;
export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>;
export type Json = JsonValue;
@@ -28,7 +32,7 @@ export type OrgRole = "admin" | "member" | "owner";
export type PasskeyDeviceType = "multiDevice" | "singleDevice";
export type Timestamp = ColumnType<Date, Date | string>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface ApiTokens {
created_at: Generated<Timestamp>;
@@ -59,7 +63,7 @@ export interface LoginRequests {
id: Generated<Int8>;
ip_address: string | null;
region: string | null;
token: string | null;
token: string;
user_agent: string | null;
user_id: number;
}

View File

@@ -0,0 +1,15 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
ignores: ["**/*.test.ts"],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -0,0 +1,27 @@
{
"name": "@reviq/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"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250529.0",
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,101 @@
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);
});
});

View File

@@ -0,0 +1,130 @@
/**
* 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",
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;
};

View File

@@ -0,0 +1 @@
export { hashPassword, verifyPassword } from "./hash-password.js";

View File

@@ -0,0 +1,7 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
},
"exclude": ["**/*.test.ts"]
}