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:
101
packages/utils/src/hash-password.test.ts
Normal file
101
packages/utils/src/hash-password.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
packages/utils/src/hash-password.ts
Normal file
130
packages/utils/src/hash-password.ts
Normal 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;
|
||||
};
|
||||
1
packages/utils/src/index.ts
Normal file
1
packages/utils/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { hashPassword, verifyPassword } from "./hash-password.js";
|
||||
Reference in New Issue
Block a user