Skip to content
Merged
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
18 changes: 18 additions & 0 deletions build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,22 @@ import { defineBuildConfig } from "unbuild";
export default defineBuildConfig({
outDir: "dist",
declaration: true,
rollup: {
emitCJS: true,
},
entries: [
"src/index",
"src/base32",
"src/base64",
"src/binary",
"src/hash",
"src/ecdsa",
"src/hex",
"src/hmac",
"src/otp",
"src/random",
"src/rsa",
"src/password",
{ input: "src/password.node.ts", name: "password.node" },
],
});
15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,20 @@
"./rsa": {
"import": "./dist/rsa.mjs",
"require": "./dist/rsa.cjs"
},
"./password": {
"node": {
"import": "./dist/password.node.mjs",
"require": "./dist/password.node.cjs"
},
"import": "./dist/password.mjs",
"require": "./dist/password.cjs"
}
},
"files": ["dist"]
"files": [
"dist"
],
"dependencies": {
"@noble/hashes": "^2.0.1"
}
}
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 93 additions & 0 deletions src/password.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { hashPassword, verifyPassword } from "./password.node";

describe("password (node:crypto)", () => {
it("hashPassword produces salt:hex format", async () => {
const hash = await hashPassword("mypassword");
const parts = hash.split(":");
expect(parts).toHaveLength(2);
expect(parts[0]).toMatch(/^[a-f0-9]{32}$/); // 16 bytes hex salt
expect(parts[1]).toMatch(/^[a-f0-9]{128}$/); // 64 bytes hex key
});

it("verifyPassword returns true for correct password", async () => {
const hash = await hashPassword("correcthorsebatterystaple");
expect(await verifyPassword(hash, "correcthorsebatterystaple")).toBe(true);
});

it("verifyPassword returns false for wrong password", async () => {
const hash = await hashPassword("correcthorsebatterystaple");
expect(await verifyPassword(hash, "wrongpassword")).toBe(false);
});

it("throws on invalid hash format", async () => {
await expect(verifyPassword("invalidhash", "password")).rejects.toThrow(
"Invalid password hash",
);
});

it("each call produces a unique hash", async () => {
const hash1 = await hashPassword("samepassword");
const hash2 = await hashPassword("samepassword");
expect(hash1).not.toBe(hash2);
});

it("handles empty password", async () => {
const hash = await hashPassword("");
expect(await verifyPassword(hash, "")).toBe(true);
expect(await verifyPassword(hash, "notempty")).toBe(false);
});

it("handles very long password", async () => {
const long = "a".repeat(1000);
const hash = await hashPassword(long);
expect(await verifyPassword(hash, long)).toBe(true);
expect(await verifyPassword(hash, "a".repeat(999))).toBe(false);
});

it("normalizes unicode passwords (NFKC)", async () => {
// fi (U+FB01, LATIN SMALL LIGATURE FI) normalizes to "fi" under NFKC
const hash = await hashPassword("\uFB01");
expect(await verifyPassword(hash, "fi")).toBe(true);
});

it("returns false for tampered key in hash", async () => {
const hash = await hashPassword("password");
const [salt, key] = hash.split(":");
const tampered = `${salt}:${"0".repeat(key!.length)}`;
expect(await verifyPassword(tampered, "password")).toBe(false);
});

it("returns false for tampered salt in hash", async () => {
const hash = await hashPassword("password");
const [, key] = hash.split(":");
const tampered = `${"0".repeat(32)}:${key}`;
expect(await verifyPassword(tampered, "password")).toBe(false);
});

it("handles special characters in password", async () => {
const special = "p@$$w0rd!#%^&*()";
const hash = await hashPassword(special);
expect(await verifyPassword(hash, special)).toBe(true);
expect(await verifyPassword(hash, "p@$$w0rd")).toBe(false);
});
});

describe("cross-compatibility: noble and node:crypto produce identical keys", () => {
it("verifies noble hash with node:crypto", async () => {
// Import noble implementation directly for cross-compat test
const { hashPassword: nobleHash, verifyPassword: nobleVerify } =
await import("./password");
const hash = await nobleHash("crossplatformtest");
// node:crypto verifyPassword should accept a noble-generated hash
expect(await verifyPassword(hash, "crossplatformtest")).toBe(true);
});

it("verifies node:crypto hash with noble", async () => {
const { verifyPassword: nobleVerify } = await import("./password");
const hash = await hashPassword("crossplatformtest");
// noble verifyPassword should accept a node:crypto-generated hash
expect(await nobleVerify(hash, "crossplatformtest")).toBe(true);
});
});
46 changes: 46 additions & 0 deletions src/password.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { scrypt, randomBytes } from "node:crypto";

const config = {
N: 16384,
r: 16,
p: 1,
dkLen: 64,
};

function generateKey(password: string, salt: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(
password.normalize("NFKC"),
salt,
config.dkLen,
{
N: config.N,
r: config.r,
p: config.p,
maxmem: 128 * config.N * config.r * 2,
},
(err, key) => {
if (err) reject(err);
else resolve(key);
},
);
});
}

export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
const key = await generateKey(password, salt);
return `${salt}:${key.toString("hex")}`;
}

export async function verifyPassword(
hash: string,
password: string,
): Promise<boolean> {
const [salt, key] = hash.split(":");
if (!salt || !key) {
throw new Error("Invalid password hash");
}
const targetKey = await generateKey(password, salt);
return targetKey.toString("hex") === key;
}
74 changes: 74 additions & 0 deletions src/password.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import { hashPassword, verifyPassword } from "./password";

describe("password (noble/scrypt)", () => {
it("hashPassword produces salt:hex format", async () => {
const hash = await hashPassword("mypassword");
const parts = hash.split(":");
expect(parts).toHaveLength(2);
expect(parts[0]).toMatch(/^[a-f0-9]{32}$/); // 16 bytes hex salt
expect(parts[1]).toMatch(/^[a-f0-9]{128}$/); // 64 bytes hex key
});

it("verifyPassword returns true for correct password", async () => {
const hash = await hashPassword("correcthorsebatterystaple");
expect(await verifyPassword(hash, "correcthorsebatterystaple")).toBe(true);
});

it("verifyPassword returns false for wrong password", async () => {
const hash = await hashPassword("correcthorsebatterystaple");
expect(await verifyPassword(hash, "wrongpassword")).toBe(false);
});

it("throws on invalid hash format", async () => {
await expect(verifyPassword("invalidhash", "password")).rejects.toThrow(
"Invalid password hash",
);
});

it("each call produces a unique hash", async () => {
const hash1 = await hashPassword("samepassword");
const hash2 = await hashPassword("samepassword");
expect(hash1).not.toBe(hash2);
});

it("handles empty password", async () => {
const hash = await hashPassword("");
expect(await verifyPassword(hash, "")).toBe(true);
expect(await verifyPassword(hash, "notempty")).toBe(false);
});

it("handles very long password", async () => {
const long = "a".repeat(1000);
const hash = await hashPassword(long);
expect(await verifyPassword(hash, long)).toBe(true);
expect(await verifyPassword(hash, "a".repeat(999))).toBe(false);
});

it("normalizes unicode passwords (NFKC)", async () => {
// fi (U+FB01, LATIN SMALL LIGATURE FI) normalizes to "fi" under NFKC
const hash = await hashPassword("\uFB01");
expect(await verifyPassword(hash, "fi")).toBe(true);
});

it("returns false for tampered key in hash", async () => {
const hash = await hashPassword("password");
const [salt, key] = hash.split(":");
const tampered = `${salt}:${"0".repeat(key!.length)}`;
expect(await verifyPassword(tampered, "password")).toBe(false);
});

it("returns false for tampered salt in hash", async () => {
const hash = await hashPassword("password");
const [, key] = hash.split(":");
const tampered = `${"0".repeat(32)}:${key}`;
expect(await verifyPassword(tampered, "password")).toBe(false);
});

it("handles special characters in password", async () => {
const special = "p@$$w0rd!#%^&*()";
const hash = await hashPassword(special);
expect(await verifyPassword(hash, special)).toBe(true);
expect(await verifyPassword(hash, "p@$$w0rd")).toBe(false);
});
});
Loading