Add byte-compatible Auth & SSO core primitives
Pure, unit-tested primitives the AtomCMS->Next.js login must reproduce exactly
(verified now with round-trip + known vectors; full end-to-end check deferred
until a real DB + APP_KEY + live emulator are available):
- password.ts: argon2id (m=65536,t=4,p=1 via hash-wasm) + bcrypt ($2y$ accepted)
verify, and the md5->argon2id on-login upgrade gated by convert_passwords
(mirrors RedirectIfTwoFactorAuthenticatable).
- sso-ticket.ts: '{hotel_name without spaces}-{uuidv4}' written to auth_ticket +
ip_current (mirrors User::ssoTicket()).
- laravel-encrypter.ts: AES-256-CBC + HMAC-SHA256 payload compatible with
Laravel encrypt()/encryptString (for existing 2FA secrets) incl. PHP string
(de)serialization.
- totp.ts: otplib Google2FA-compatible TOTP verify (SHA1/6/30).
Libs: hash-wasm + bcryptjs + otplib (pure JS/WASM, no native build). 28 tests.
This commit is contained in:
1 parent
5fd40feaa6
commit
ec2d46e583
11 files changed
+511
No files matched your search
@@ -14,6 +14,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-mariadb": "^7.8.0",
|
"@prisma/adapter-mariadb": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"hash-wasm": "^4.12.0",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+76
@@ -14,6 +14,15 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^7.8.0
|
specifier: ^7.8.0
|
||||||
version: 7.8.0([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
version: 7.8.0([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^3.0.2
|
||||||
|
version: 3.0.3
|
||||||
|
hash-wasm:
|
||||||
|
specifier: ^4.12.0
|
||||||
|
version: 4.12.0
|
||||||
|
otplib:
|
||||||
|
specifier: ^12.0.1
|
||||||
|
version: 12.0.1
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.0
|
specifier: ^3.24.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -671,6 +680,24 @@ packages:
|
|||||||
'@kurkle/[email protected]':
|
'@kurkle/[email protected]':
|
||||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
|
||||||
|
deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
|
||||||
|
deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
|
||||||
|
deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
|
||||||
|
|
||||||
'@petamoriken/[email protected]':
|
'@petamoriken/[email protected]':
|
||||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||||
|
|
||||||
@@ -1007,6 +1034,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==}
|
resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==}
|
||||||
|
|
||||||
@@ -1277,6 +1308,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==}
|
resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
@@ -1354,6 +1388,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1500,6 +1537,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
|
||||||
|
engines: {node: '>=0.2.6'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -1938,6 +1979,29 @@ snapshots:
|
|||||||
|
|
||||||
'@kurkle/[email protected]': {}
|
'@kurkle/[email protected]': {}
|
||||||
|
|
||||||
|
'@otplib/[email protected]': {}
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
thirty-two: 1.0.2
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/plugin-crypto': 12.0.1
|
||||||
|
'@otplib/plugin-thirty-two': 12.0.1
|
||||||
|
|
||||||
|
'@otplib/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/plugin-crypto': 12.0.1
|
||||||
|
'@otplib/plugin-thirty-two': 12.0.1
|
||||||
|
|
||||||
'@petamoriken/[email protected]': {}
|
'@petamoriken/[email protected]': {}
|
||||||
|
|
||||||
'@prisma/[email protected]':
|
'@prisma/[email protected]':
|
||||||
@@ -2233,6 +2297,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -2491,6 +2557,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -2567,6 +2635,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/preset-default': 12.0.1
|
||||||
|
'@otplib/preset-v11': 12.0.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -2710,6 +2784,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export {
|
||||||
|
checkLogin,
|
||||||
|
hashPassword,
|
||||||
|
isMd5Of,
|
||||||
|
md5Hex,
|
||||||
|
verifyPassword,
|
||||||
|
type LoginCheck,
|
||||||
|
} from "./password";
|
||||||
|
export { generateSsoTicket, issueSsoTicket, type SsoUserUpdater } from "./sso-ticket";
|
||||||
|
export {
|
||||||
|
LaravelEncrypter,
|
||||||
|
phpSerializeString,
|
||||||
|
phpUnserializeString,
|
||||||
|
} from "./laravel-encrypter";
|
||||||
|
export { generateTotp, totpKeyUri, verifyTotp } from "./totp";
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
LaravelEncrypter,
|
||||||
|
phpSerializeString,
|
||||||
|
phpUnserializeString,
|
||||||
|
} from "./laravel-encrypter";
|
||||||
|
|
||||||
|
// A deterministic 32-byte key in Laravel's "base64:" form.
|
||||||
|
const APP_KEY = `base64:${Buffer.from("0123456789abcdef0123456789abcdef").toString("base64")}`;
|
||||||
|
|
||||||
|
describe("LaravelEncrypter", () => {
|
||||||
|
it("rejects a key that is not 32 bytes", () => {
|
||||||
|
expect(() => new LaravelEncrypter("base64:c2hvcnQ=")).toThrow(/32 bytes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips encrypt/decrypt (serialize=true, like Laravel encrypt())", () => {
|
||||||
|
const enc = new LaravelEncrypter(APP_KEY);
|
||||||
|
const secret = "JBSWY3DPEHPK3PXP"; // a TOTP secret
|
||||||
|
const payload = enc.encrypt(secret);
|
||||||
|
expect(payload).not.toContain(secret);
|
||||||
|
expect(enc.decrypt(payload)).toBe(secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips encryptString/decryptString (serialize=false)", () => {
|
||||||
|
const enc = new LaravelEncrypter(APP_KEY);
|
||||||
|
const payload = enc.encryptString("hello world");
|
||||||
|
expect(enc.decryptString(payload)).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when the MAC is tampered", () => {
|
||||||
|
const enc = new LaravelEncrypter(APP_KEY);
|
||||||
|
const payload = enc.encrypt("x");
|
||||||
|
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||||
|
json.mac = "00".repeat(32);
|
||||||
|
const tampered = Buffer.from(JSON.stringify(json), "utf8").toString("base64");
|
||||||
|
expect(() => enc.decrypt(tampered)).toThrow(/MAC is invalid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decrypts a payload produced with a fresh instance of the same key", () => {
|
||||||
|
const payload = new LaravelEncrypter(APP_KEY).encrypt("shared");
|
||||||
|
expect(new LaravelEncrypter(APP_KEY).decrypt(payload)).toBe("shared");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("php string (de)serialization", () => {
|
||||||
|
it("serializes by byte length", () => {
|
||||||
|
expect(phpSerializeString("hello")).toBe('s:5:"hello";');
|
||||||
|
expect(phpSerializeString("café")).toBe('s:5:"café";'); // é is 2 bytes
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips including multibyte", () => {
|
||||||
|
expect(phpUnserializeString(phpSerializeString("café"))).toBe("café");
|
||||||
|
expect(phpUnserializeString('s:5:"hello";')).toBe("hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-implementation of Laravel's Illuminate\Encryption\Encrypter for the
|
||||||
|
* AES-256-CBC cipher (config/app.php cipher = 'AES-256-CBC'). Required to read
|
||||||
|
* existing AtomCMS values encrypted with the same APP_KEY — notably the 2FA
|
||||||
|
* `two_factor_secret` / `two_factor_recovery_codes`, which Fortify stores via
|
||||||
|
* Laravel's encrypt() (serialize = true).
|
||||||
|
*
|
||||||
|
* Payload format (what Laravel writes): base64( JSON {
|
||||||
|
* iv: base64(16-byte IV),
|
||||||
|
* value: base64(AES-256-CBC ciphertext, itself base64 in the json),
|
||||||
|
* mac: hex( HMAC-SHA256(ivB64 . valueB64, key) ),
|
||||||
|
* } )
|
||||||
|
*/
|
||||||
|
export class LaravelEncrypter {
|
||||||
|
private readonly key: Buffer;
|
||||||
|
|
||||||
|
/** APP_KEY is "base64:...." (or a raw 32-byte string). */
|
||||||
|
constructor(appKey: string) {
|
||||||
|
const raw = appKey.startsWith("base64:")
|
||||||
|
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||||
|
: Buffer.from(appKey, "utf8");
|
||||||
|
if (raw.length !== 32) {
|
||||||
|
throw new Error(`APP_KEY must decode to 32 bytes for AES-256-CBC (got ${raw.length})`);
|
||||||
|
}
|
||||||
|
this.key = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(value: string, serialize = true): string {
|
||||||
|
const iv = randomBytes(16);
|
||||||
|
const data = serialize ? phpSerializeString(value) : value;
|
||||||
|
const cipher = createCipheriv("aes-256-cbc", this.key, iv);
|
||||||
|
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||||
|
const ivB64 = iv.toString("base64");
|
||||||
|
const mac = this.hmac(ivB64, valueB64);
|
||||||
|
const payload = JSON.stringify({ iv: ivB64, value: valueB64, mac });
|
||||||
|
return Buffer.from(payload, "utf8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(payload: string, serialize = true): string {
|
||||||
|
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
|
||||||
|
iv: string;
|
||||||
|
value: string;
|
||||||
|
mac: string;
|
||||||
|
};
|
||||||
|
const expected = this.hmac(json.iv, json.value);
|
||||||
|
const a = Buffer.from(expected, "hex");
|
||||||
|
const b = Buffer.from(json.mac, "hex");
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
throw new Error("The MAC is invalid.");
|
||||||
|
}
|
||||||
|
const iv = Buffer.from(json.iv, "base64");
|
||||||
|
const decipher = createDecipheriv("aes-256-cbc", this.key, iv);
|
||||||
|
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||||
|
return serialize ? phpUnserializeString(plain) : plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laravel's encryptString/decryptString use serialize = false. */
|
||||||
|
encryptString(value: string): string {
|
||||||
|
return this.encrypt(value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptString(payload: string): string {
|
||||||
|
return this.decrypt(payload, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hmac(ivB64: string, valueB64: string): string {
|
||||||
|
return createHmac("sha256", this.key).update(ivB64 + valueB64).digest("hex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PHP serialize() for a string: s:<byteLength>:"<value>"; */
|
||||||
|
export function phpSerializeString(value: string): string {
|
||||||
|
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PHP unserialize() for a serialized string payload. */
|
||||||
|
export function phpUnserializeString(serialized: string): string {
|
||||||
|
const m = /^s:(\d+):"/.exec(serialized);
|
||||||
|
if (!m) throw new Error("Not a serialized PHP string");
|
||||||
|
const byteLen = Number(m[1]);
|
||||||
|
const start = m[0].length;
|
||||||
|
// Slice by BYTE length (PHP counts bytes), then back to a JS string.
|
||||||
|
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||||
|
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||||
|
);
|
||||||
|
return bytes.subarray(0, byteLen).toString("utf8");
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { hash as bcryptHash } from "bcryptjs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
checkLogin,
|
||||||
|
hashPassword,
|
||||||
|
isMd5Of,
|
||||||
|
md5Hex,
|
||||||
|
verifyPassword,
|
||||||
|
} from "./password";
|
||||||
|
|
||||||
|
describe("md5Hex", () => {
|
||||||
|
it("matches PHP md5() on canonical vectors", () => {
|
||||||
|
expect(md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e");
|
||||||
|
expect(md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("argon2id", () => {
|
||||||
|
it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => {
|
||||||
|
const h = await hashPassword("s3cret!");
|
||||||
|
expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/);
|
||||||
|
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||||
|
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bcrypt", () => {
|
||||||
|
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
|
||||||
|
const h = await bcryptHash("hunter2", 10); // bcryptjs emits $2a$
|
||||||
|
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||||
|
// PHP stores $2y$ — bcryptjs must accept it as equivalent.
|
||||||
|
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
||||||
|
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
||||||
|
expect(await verifyPassword("nope", h)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isMd5Of", () => {
|
||||||
|
it("detects a legacy md5 password", () => {
|
||||||
|
expect(isMd5Of("habbo", md5Hex("habbo"))).toBe(true);
|
||||||
|
expect(isMd5Of("habbo", md5Hex("other"))).toBe(false);
|
||||||
|
expect(isMd5Of("habbo", "not-a-hash")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkLogin", () => {
|
||||||
|
it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => {
|
||||||
|
const stored = md5Hex("oldpass");
|
||||||
|
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||||
|
expect(res.valid).toBe(true);
|
||||||
|
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
|
||||||
|
// The upgraded hash verifies the same password.
|
||||||
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
||||||
|
const stored = md5Hex("oldpass");
|
||||||
|
const res = await checkLogin("oldpass", stored, { convertPasswords: false });
|
||||||
|
expect(res.valid).toBe(false);
|
||||||
|
expect(res.upgradedHash).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates an existing argon2id hash with no upgrade", async () => {
|
||||||
|
const stored = await hashPassword("modern");
|
||||||
|
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||||
|
expect(res.valid).toBe(true);
|
||||||
|
expect(res.upgradedHash).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { compare as bcryptCompare } from "bcryptjs";
|
||||||
|
import { argon2id, argon2Verify } from "hash-wasm";
|
||||||
|
|
||||||
|
// AtomCMS hashing (config/hashing.php): default driver argon2id with
|
||||||
|
// memory=65536 KiB, time=4, threads=1; bcrypt rounds=12 as the legacy fallback.
|
||||||
|
// The game emulator validates the SAME users.password hash, so these must match.
|
||||||
|
const ARGON2_PARAMS = {
|
||||||
|
parallelism: 1,
|
||||||
|
iterations: 4,
|
||||||
|
memorySize: 65536, // KiB
|
||||||
|
hashLength: 32,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Lowercase hex md5 of a UTF-8 string (matches PHP md5()). */
|
||||||
|
export function md5Hex(input: string): string {
|
||||||
|
return createHash("md5").update(input, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Produce an argon2id hash in PHC format identical to PHP's PASSWORD_ARGON2ID. */
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return argon2id({
|
||||||
|
password,
|
||||||
|
salt: randomBytes(16),
|
||||||
|
outputType: "encoded",
|
||||||
|
...ARGON2_PARAMS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */
|
||||||
|
export function isMd5Of(password: string, stored: string): boolean {
|
||||||
|
return /^[a-f0-9]{32}$/i.test(stored) && md5Hex(password) === stored.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a password against a stored hash, auto-detecting the algorithm the way
|
||||||
|
* Laravel's Hash::check does. Returns false for unknown/legacy formats (md5 is
|
||||||
|
* handled by the conversion path in checkLogin, not here).
|
||||||
|
*/
|
||||||
|
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||||
|
if (stored.startsWith("$argon2")) {
|
||||||
|
try {
|
||||||
|
return await argon2Verify({ password, hash: stored });
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/^\$2[aby]\$/.test(stored)) {
|
||||||
|
try {
|
||||||
|
return await bcryptCompare(password, stored);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginCheck {
|
||||||
|
valid: boolean;
|
||||||
|
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
|
||||||
|
upgradedHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full AtomCMS credential check including the md5 -> argon2id on-login upgrade
|
||||||
|
* (gated by `convertPasswords`, i.e. config('habbo.site.convert_passwords')).
|
||||||
|
* Mirrors RedirectIfTwoFactorAuthenticatable::convertUserPassword + validate.
|
||||||
|
*/
|
||||||
|
export async function checkLogin(
|
||||||
|
password: string,
|
||||||
|
stored: string,
|
||||||
|
opts: { convertPasswords: boolean },
|
||||||
|
): Promise<LoginCheck> {
|
||||||
|
if (opts.convertPasswords && isMd5Of(password, stored)) {
|
||||||
|
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||||
|
}
|
||||||
|
return { valid: await verifyPassword(password, stored) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { generateSsoTicket, issueSsoTicket } from "./sso-ticket";
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
describe("generateSsoTicket", () => {
|
||||||
|
it("uses '{hotelName-without-spaces}-{uuidv4}'", () => {
|
||||||
|
const t = generateSsoTicket("Atom Hotel");
|
||||||
|
expect(t.startsWith("AtomHotel-")).toBe(true);
|
||||||
|
expect(UUID_RE.test(t.slice("AtomHotel-".length))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips every space in the hotel name", () => {
|
||||||
|
expect(generateSsoTicket("My Cool Hotel").startsWith("MyCoolHotel-")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a fresh ticket each call", () => {
|
||||||
|
expect(generateSsoTicket("Atom")).not.toBe(generateSsoTicket("Atom"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("issueSsoTicket", () => {
|
||||||
|
it("writes auth_ticket AND ip_current and returns the ticket", async () => {
|
||||||
|
const update = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const db = { user: { update } };
|
||||||
|
const ticket = await issueSsoTicket(db, 42, "Atom Hotel", "1.2.3.4");
|
||||||
|
|
||||||
|
expect(ticket.startsWith("AtomHotel-")).toBe(true);
|
||||||
|
expect(update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 42 },
|
||||||
|
data: { authTicket: ticket, ipCurrent: "1.2.3.4" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the SSO ticket exactly like AtomCMS's User::ssoTicket():
|
||||||
|
* $hotelName = Str::replace(' ', '', setting('hotel_name'));
|
||||||
|
* sprintf('%s-%s', $hotelName, Str::uuid());
|
||||||
|
* i.e. the hotel name with ALL spaces removed, a dash, then a v4 UUID.
|
||||||
|
* The emulator validates this exact value when the Nitro/Flash client connects.
|
||||||
|
*/
|
||||||
|
export function generateSsoTicket(hotelName: string): string {
|
||||||
|
const normalized = hotelName.replace(/ /g, "");
|
||||||
|
return `${normalized}-${randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal shape of the Prisma client this needs (keeps it unit-testable). */
|
||||||
|
export interface SsoUserUpdater {
|
||||||
|
user: {
|
||||||
|
update(args: {
|
||||||
|
where: { id: number };
|
||||||
|
data: { authTicket: string; ipCurrent: string };
|
||||||
|
}): Promise<unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a ticket and persist it like AtomCMS: writes auth_ticket AND
|
||||||
|
* ip_current on the user, then returns the ticket for the client launcher.
|
||||||
|
*/
|
||||||
|
export async function issueSsoTicket(
|
||||||
|
db: SsoUserUpdater,
|
||||||
|
userId: number,
|
||||||
|
hotelName: string,
|
||||||
|
ip: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const ticket = generateSsoTicket(hotelName);
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { authTicket: ticket, ipCurrent: ip },
|
||||||
|
});
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { generateTotp, totpKeyUri, verifyTotp } from "./totp";
|
||||||
|
|
||||||
|
const SECRET = "JBSWY3DPEHPK3PXP"; // standard base32 test secret
|
||||||
|
|
||||||
|
describe("totp", () => {
|
||||||
|
it("verifies the current generated code", () => {
|
||||||
|
const code = generateTotp(SECRET);
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
expect(verifyTotp(code, SECRET)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a wrong code", () => {
|
||||||
|
expect(verifyTotp("000000", SECRET)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed input without throwing", () => {
|
||||||
|
expect(verifyTotp("not-a-code", SECRET)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds an otpauth provisioning URI", () => {
|
||||||
|
const uri = totpKeyUri(SECRET, "alice", "AtomHotel");
|
||||||
|
expect(uri.startsWith("otpauth://totp/")).toBe(true);
|
||||||
|
expect(uri).toContain("secret=" + SECRET);
|
||||||
|
expect(uri).toContain("issuer=AtomHotel");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { authenticator } from "otplib";
|
||||||
|
|
||||||
|
// Laravel Fortify uses pragmarx/google2fa: HMAC-SHA1, 6 digits, 30s period.
|
||||||
|
// otplib already defaults to SHA1/6/30; window=1 tolerates one step of skew.
|
||||||
|
authenticator.options = { window: 1 };
|
||||||
|
|
||||||
|
/** Verify a 6-digit TOTP code against a base32 secret. */
|
||||||
|
export function verifyTotp(token: string, secret: string): boolean {
|
||||||
|
try {
|
||||||
|
return authenticator.check(token, secret);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current TOTP code for a secret (used in tests / tooling). */
|
||||||
|
export function generateTotp(secret: string): string {
|
||||||
|
return authenticator.generate(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** otpauth:// URI for provisioning a QR code. */
|
||||||
|
export function totpKeyUri(secret: string, accountName: string, issuer: string): string {
|
||||||
|
return authenticator.keyuri(accountName, issuer, secret);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user