security: switch default hashing to Argon2id, fix tests
- hashPassword now uses Argon2id (memory-hard, GPU-resistant) via hash-wasm - verifyPassword checks both Argon2id and bcrypt - Legacy hashes (bcrypt, argon2, md5, sha1, sha256, sha512, combined, salted) auto-migrate to Argon2id on successful login - Updated all password tests to expect Argon2id format - Register validation: min 12 chars, max 128, upper+lower+digit+special required - Username restricted to [A-Za-z0-9_-], reserved names blocked - Disposable email domains blocked - Fixed parameter names for hash-wasm argon2id API (memorySize, iterations, parallelism, hashLength)
This commit is contained in:
1 parent
ac60a867d9
commit
b13b3a50ff
4 files changed
+94
-40
No files matched your search
@@ -139,7 +139,7 @@ describe("register", () => {
|
|||||||
expect(state.hashPassword).toHaveBeenCalledWith("Secret1234!@");
|
expect(state.hashPassword).toHaveBeenCalledWith("Secret1234!@");
|
||||||
expect(state.insert).toHaveBeenCalledOnce();
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
expect(state.insert.mock.calls[0][0]).toBe(User);
|
expect(state.insert.mock.calls[0][0]).toBe(User);
|
||||||
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
username: "Alice_123",
|
username: "Alice_123",
|
||||||
password: "hashed:Secret1234!@",
|
password: "hashed:Secret1234!@",
|
||||||
mail: null,
|
mail: null,
|
||||||
@@ -214,7 +214,10 @@ expect(state.insert.mock.calls[0][1]).toMatchObject({
|
|||||||
it("rejects passwords without an uppercase letter", async () => {
|
it("rejects passwords without an uppercase letter", async () => {
|
||||||
const result = await register(
|
const result = await register(
|
||||||
PREV,
|
PREV,
|
||||||
buildForm({ password: "secret1234!@", password_confirmation: "secret1234!@" }),
|
buildForm({
|
||||||
|
password: "secret1234!@",
|
||||||
|
password_confirmation: "secret1234!@",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(result.error).toContain("uppercase");
|
expect(result.error).toContain("uppercase");
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-15
@@ -16,24 +16,63 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
|
|
||||||
// Reserved usernames that can never be registered (prevent impersonation/admin confusion).
|
// Reserved usernames that can never be registered (prevent impersonation/admin confusion).
|
||||||
const RESERVED_USERNAMES = new Set([
|
const RESERVED_USERNAMES = new Set([
|
||||||
"admin", "root", "system", "moderator", "mod", "staff", "support",
|
"admin",
|
||||||
"help", "service", "api", "webmaster", "postmaster", "hostmaster",
|
"root",
|
||||||
"administrator", "superuser", "sysadmin", "nobody", "anonymous",
|
"system",
|
||||||
"guest", "default", "test", "demo", "example", "info", "security",
|
"moderator",
|
||||||
"abuse", "noreply", "donotreply", "bot", "crawler", "indexer",
|
"mod",
|
||||||
|
"staff",
|
||||||
|
"support",
|
||||||
|
"help",
|
||||||
|
"service",
|
||||||
|
"api",
|
||||||
|
"webmaster",
|
||||||
|
"postmaster",
|
||||||
|
"hostmaster",
|
||||||
|
"administrator",
|
||||||
|
"superuser",
|
||||||
|
"sysadmin",
|
||||||
|
"nobody",
|
||||||
|
"anonymous",
|
||||||
|
"guest",
|
||||||
|
"default",
|
||||||
|
"test",
|
||||||
|
"demo",
|
||||||
|
"example",
|
||||||
|
"info",
|
||||||
|
"security",
|
||||||
|
"abuse",
|
||||||
|
"noreply",
|
||||||
|
"donotreply",
|
||||||
|
"bot",
|
||||||
|
"crawler",
|
||||||
|
"indexer",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Disposable/temporary email domains (subset, expandable via settings).
|
// Disposable/temporary email domains (subset, expandable via settings).
|
||||||
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
||||||
"10minutemail.com", "guerrillamail.com", "mailinator.com",
|
"10minutemail.com",
|
||||||
"tempmail.com", "throwawaymail.com", "yopmail.com", "trashmail.com",
|
"guerrillamail.com",
|
||||||
"fakeinbox.com", "spamgourmet.com", "getnada.com", "maildrop.cc",
|
"mailinator.com",
|
||||||
|
"tempmail.com",
|
||||||
|
"throwawaymail.com",
|
||||||
|
"yopmail.com",
|
||||||
|
"trashmail.com",
|
||||||
|
"fakeinbox.com",
|
||||||
|
"spamgourmet.com",
|
||||||
|
"getnada.com",
|
||||||
|
"maildrop.cc",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isReservedUsername(username: string): boolean {
|
function isReservedUsername(username: string): boolean {
|
||||||
const lower = username.toLowerCase();
|
const lower = username.toLowerCase();
|
||||||
if (RESERVED_USERNAMES.has(lower)) return true;
|
if (RESERVED_USERNAMES.has(lower)) return true;
|
||||||
if (lower.startsWith("admin") || lower.startsWith("mod") || lower.startsWith("staff")) return true;
|
if (
|
||||||
|
lower.startsWith("admin") ||
|
||||||
|
lower.startsWith("mod") ||
|
||||||
|
lower.startsWith("staff")
|
||||||
|
)
|
||||||
|
return true;
|
||||||
if (/^(x|www|mail|ftp|smtp|pop|imap|dns|ns[0-9]*)$/.test(lower)) return true;
|
if (/^(x|www|mail|ftp|smtp|pop|imap|dns|ns[0-9]*)$/.test(lower)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -43,19 +82,26 @@ function hasDisposableEmailDomain(email: string): boolean {
|
|||||||
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z
|
||||||
|
.object({
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
.min(3, "Username must be at least 3 characters")
|
.min(3, "Username must be at least 3 characters")
|
||||||
.max(25, "Username must be at most 25 characters")
|
.max(25, "Username must be at most 25 characters")
|
||||||
.regex(/^[A-Za-z0-9_-]+$/, "Username may only contain letters, numbers, underscore and hyphen")
|
.regex(
|
||||||
|
/^[A-Za-z0-9_-]+$/,
|
||||||
|
"Username may only contain letters, numbers, underscore and hyphen",
|
||||||
|
)
|
||||||
.refine((u) => !isReservedUsername(u), "This username is reserved"),
|
.refine((u) => !isReservedUsername(u), "This username is reserved"),
|
||||||
mail: z
|
mail: z
|
||||||
.string()
|
.string()
|
||||||
.email("Enter a valid email address")
|
.email("Enter a valid email address")
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal(""))
|
.or(z.literal(""))
|
||||||
.refine((e) => !e || !hasDisposableEmailDomain(e), "Temporary email domains are not allowed"),
|
.refine(
|
||||||
|
(e) => !e || !hasDisposableEmailDomain(e),
|
||||||
|
"Temporary email domains are not allowed",
|
||||||
|
),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(12, "Password must be at least 12 characters") // Increased min length
|
.min(12, "Password must be at least 12 characters") // Increased min length
|
||||||
@@ -63,13 +109,17 @@ const registerSchema = z.object({
|
|||||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||||
.regex(/[0-9]/, "Password must contain at least one digit")
|
.regex(/[0-9]/, "Password must contain at least one digit")
|
||||||
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"), // Added special character requirement
|
.regex(
|
||||||
|
/[^A-Za-z0-9]/,
|
||||||
|
"Password must contain at least one special character",
|
||||||
|
), // Added special character requirement
|
||||||
passwordConfirmation: z.string(),
|
passwordConfirmation: z.string(),
|
||||||
look: z.string().optional(),
|
look: z.string().optional(),
|
||||||
}).refine((data) => data.password === data.passwordConfirmation, {
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirmation, {
|
||||||
message: "Passwords do not match",
|
message: "Passwords do not match",
|
||||||
path: ["passwordConfirmation"],
|
path: ["passwordConfirmation"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ describe("sha512Hex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("hashPassword", () => {
|
describe("hashPassword", () => {
|
||||||
it("emits a bcrypt hash and round-trips", async () => {
|
it("emits an Argon2id hash and round-trips", async () => {
|
||||||
const h = await hashPassword("s3cret!");
|
const h = await hashPassword("s3cret!");
|
||||||
expect(h).toMatch(/^\$2[aby]\$/);
|
expect(h).toMatch(/^\$argon2id\$/);
|
||||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -209,31 +209,31 @@ describe("isSaltedDigestOf", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("verifyPassword", () => {
|
describe("verifyPassword", () => {
|
||||||
it("verifies bcrypt hashes", async () => {
|
it("verifies Argon2id hashes", async () => {
|
||||||
const h = await hashPassword("hunter2");
|
const h = await hashPassword("hunter2");
|
||||||
expect(h).toMatch(/^\$2[aby]\$/);
|
expect(h).toMatch(/^\$argon2id\$/);
|
||||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||||
expect(await verifyPassword("nope", h)).toBe(false);
|
expect(await verifyPassword("nope", h)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("checkLogin", () => {
|
describe("checkLogin", () => {
|
||||||
it("upgrades a legacy md5 hash to bcrypt", async () => {
|
it("upgrades a legacy md5 hash to Argon2id", async () => {
|
||||||
const stored = await md5Hex("oldpass");
|
const stored = await md5Hex("oldpass");
|
||||||
const res = await checkLogin("oldpass", stored);
|
const res = await checkLogin("oldpass", stored);
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migrates a legacy argon2id hash to bcrypt", async () => {
|
it("migrates a legacy argon2id hash to Argon2id", async () => {
|
||||||
const stored =
|
const stored =
|
||||||
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
|
||||||
const res = await checkLogin("test-password-123", stored);
|
const res = await checkLogin("test-password-123", stored);
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [name, hashThePassword] of [
|
for (const [name, hashThePassword] of [
|
||||||
@@ -241,48 +241,48 @@ describe("checkLogin", () => {
|
|||||||
["sha256", sha256Hex],
|
["sha256", sha256Hex],
|
||||||
["sha512", sha512Hex],
|
["sha512", sha512Hex],
|
||||||
] as const) {
|
] as const) {
|
||||||
it(`migrates a legacy ${name} hash to bcrypt`, async () => {
|
it(`migrates a legacy ${name} hash to Argon2id`, async () => {
|
||||||
const stored = await hashThePassword("oldpass");
|
const stored = await hashThePassword("oldpass");
|
||||||
const res = await checkLogin("oldpass", stored);
|
const res = await checkLogin("oldpass", stored);
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it("migrates a combined digest hash to bcrypt (md5(md5(pass)))", async () => {
|
it("migrates a combined digest hash to Argon2id (md5(md5(pass)))", async () => {
|
||||||
const stored = await md5Hex(await md5Hex("oldpass"));
|
const stored = await md5Hex(await md5Hex("oldpass"));
|
||||||
const res = await checkLogin("oldpass", stored);
|
const res = await checkLogin("oldpass", stored);
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migrates a combined digest hash to bcrypt (sha1(md5(pass)))", async () => {
|
it("migrates a combined digest hash to Argon2id (sha1(md5(pass)))", async () => {
|
||||||
const stored = await sha1Hex(await md5Hex("oldpass"));
|
const stored = await sha1Hex(await md5Hex("oldpass"));
|
||||||
const res = await checkLogin("oldpass", stored);
|
const res = await checkLogin("oldpass", stored);
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migrates a salted md5 hash to bcrypt (md5(salt+password))", async () => {
|
it("migrates a salted md5 hash to Argon2id (md5(salt+password))", async () => {
|
||||||
const salt = "pepper123";
|
const salt = "pepper123";
|
||||||
const stored = `${await md5Hex(`${salt}oldpass`)}:${salt}`;
|
const stored = `${await md5Hex(`${salt}oldpass`)}:${salt}`;
|
||||||
const res = await checkLogin("oldpass", stored);
|
const res = await checkLogin("oldpass", stored);
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migrates a salted sha256 hash to bcrypt (sha256(salt+password))", async () => {
|
it("migrates a salted sha256 hash to Argon2id (sha256(salt+password))", async () => {
|
||||||
const salt = "abc123";
|
const salt = "abc123";
|
||||||
const stored = `${salt}:${await sha256Hex(`${salt}oldpass`)}`;
|
const stored = `${salt}:${await sha256Hex(`${salt}oldpass`)}`;
|
||||||
const res = await checkLogin("oldpass", stored);
|
const res = await checkLogin("oldpass", stored);
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a wrong password for salted/combined hashes", async () => {
|
it("rejects a wrong password for salted/combined hashes", async () => {
|
||||||
@@ -292,10 +292,10 @@ describe("checkLogin", () => {
|
|||||||
expect((await checkLogin("wrongpass", combined)).valid).toBe(false);
|
expect((await checkLogin("wrongpass", combined)).valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a raw plaintext password and upgrades it to bcrypt", async () => {
|
it("accepts a raw plaintext password and upgrades it to Argon2id", async () => {
|
||||||
const res = await checkLogin("hunter44", "hunter44");
|
const res = await checkLogin("hunter44", "hunter44");
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
expect(await verifyPassword("hunter44", res.upgradedHash as string)).toBe(
|
expect(await verifyPassword("hunter44", res.upgradedHash as string)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import { env } from "@/env";
|
|||||||
|
|
||||||
/** Argon2id parameters — memory-hard, GPU-resistant. */
|
/** Argon2id parameters — memory-hard, GPU-resistant. */
|
||||||
const ARGON2_CONFIG = {
|
const ARGON2_CONFIG = {
|
||||||
memoryCost: 19456, // ~19 MiB
|
memorySize: 19456, // ~19 MiB
|
||||||
timeCost: 2,
|
iterations: 2,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
outputLen: 32,
|
hashLength: 32,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Hash new passwords with Argon2id (best practice 2024+). */
|
/** Hash new passwords with Argon2id (best practice 2024+). */
|
||||||
@@ -210,6 +210,7 @@ export async function verifyPassword(
|
|||||||
password: string,
|
password: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (/^\$argon2/.test(stored)) return isArgon2Of(password, stored);
|
||||||
return isBcryptOf(password, stored);
|
return isBcryptOf(password, stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user