@vex-chat/spire 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -38
- package/dist/ClientManager.d.ts +1 -3
- package/dist/ClientManager.js +6 -48
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +25 -5
- package/dist/Database.js +54 -16
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +0 -3
- package/dist/Spire.js +35 -73
- package/dist/Spire.js.map +1 -1
- package/dist/__tests__/Database.spec.js +0 -14
- package/dist/__tests__/Database.spec.js.map +1 -1
- package/dist/db/schema.d.ts +1 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.d.ts +3 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +12 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +1 -0
- package/dist/run.js +0 -1
- package/dist/run.js.map +1 -1
- package/dist/server/avatar.d.ts +1 -3
- package/dist/server/avatar.js +7 -11
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/errors.d.ts +3 -6
- package/dist/server/errors.js +2 -28
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.d.ts +1 -2
- package/dist/server/file.js +5 -7
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +1 -2
- package/dist/server/index.js +22 -33
- package/dist/server/index.js.map +1 -1
- package/dist/server/invite.d.ts +1 -2
- package/dist/server/invite.js +1 -1
- package/dist/server/invite.js.map +1 -1
- package/dist/server/rateLimit.d.ts +3 -3
- package/dist/server/rateLimit.js +6 -6
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +1 -2
- package/dist/server/user.js +3 -5
- package/dist/server/user.js.map +1 -1
- package/dist/utils/jwtSecret.d.ts +3 -3
- package/dist/utils/jwtSecret.js +5 -6
- package/dist/utils/jwtSecret.js.map +1 -1
- package/dist/utils/loadEnv.js +1 -1
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +6 -4
- package/src/ClientManager.ts +6 -58
- package/src/Database.ts +83 -21
- package/src/Spire.ts +37 -108
- package/src/__tests__/Database.spec.ts +0 -17
- package/src/db/schema.ts +1 -0
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +18 -0
- package/src/run.ts +0 -1
- package/src/server/avatar.ts +7 -14
- package/src/server/errors.ts +3 -32
- package/src/server/file.ts +5 -10
- package/src/server/index.ts +23 -37
- package/src/server/invite.ts +0 -2
- package/src/server/rateLimit.ts +6 -6
- package/src/server/user.ts +2 -6
- package/src/utils/jwtSecret.ts +5 -6
- package/src/utils/loadEnv.ts +1 -1
- package/dist/utils/createLogger.d.ts +0 -5
- package/dist/utils/createLogger.js +0 -35
- package/dist/utils/createLogger.js.map +0 -1
- package/src/utils/createLogger.ts +0 -47
package/dist/server/rateLimit.js
CHANGED
|
@@ -11,14 +11,14 @@ const keyByIp = (req) => ipKeyGenerator(req.ip ?? "");
|
|
|
11
11
|
/**
|
|
12
12
|
* Global per-IP limiter. Applied app-wide via `api.use(globalLimiter)`.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* 3000 requests per 15 minutes per client IP. A human chatting via a
|
|
15
15
|
* browser or the libvex client won't come close; a single-host DoS
|
|
16
16
|
* gets throttled quickly.
|
|
17
17
|
*/
|
|
18
18
|
export const globalLimiter = rateLimit({
|
|
19
19
|
keyGenerator: keyByIp,
|
|
20
20
|
legacyHeaders: false,
|
|
21
|
-
limit:
|
|
21
|
+
limit: 3000,
|
|
22
22
|
standardHeaders: "draft-7",
|
|
23
23
|
windowMs: 15 * 60 * 1000,
|
|
24
24
|
});
|
|
@@ -26,7 +26,7 @@ export const globalLimiter = rateLimit({
|
|
|
26
26
|
* Strict auth endpoint limiter. Applied per-route to /auth, /register,
|
|
27
27
|
* and /auth/device.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
29
|
+
* 50 failed attempts per 15 minutes per IP. Successful logins don't
|
|
30
30
|
* count (`skipSuccessfulRequests`), so a normal user doesn't lock
|
|
31
31
|
* themselves out by fat-fingering a password once. Blocks brute force
|
|
32
32
|
* (CWE-307) without harming UX.
|
|
@@ -34,7 +34,7 @@ export const globalLimiter = rateLimit({
|
|
|
34
34
|
export const authLimiter = rateLimit({
|
|
35
35
|
keyGenerator: keyByIp,
|
|
36
36
|
legacyHeaders: false,
|
|
37
|
-
limit:
|
|
37
|
+
limit: 50,
|
|
38
38
|
skipSuccessfulRequests: true,
|
|
39
39
|
standardHeaders: "draft-7",
|
|
40
40
|
windowMs: 15 * 60 * 1000,
|
|
@@ -45,13 +45,13 @@ export const authLimiter = rateLimit({
|
|
|
45
45
|
* upload attempts per minute so an attacker can't force spire to
|
|
46
46
|
* spend CPU/IO on repeated large-body parses.
|
|
47
47
|
*
|
|
48
|
-
*
|
|
48
|
+
* 200 uploads per minute per IP — generous for a chat client (rapid-
|
|
49
49
|
* fire image attachments) but tight enough to shield the disk.
|
|
50
50
|
*/
|
|
51
51
|
export const uploadLimiter = rateLimit({
|
|
52
52
|
keyGenerator: keyByIp,
|
|
53
53
|
legacyHeaders: false,
|
|
54
|
-
limit:
|
|
54
|
+
limit: 200,
|
|
55
55
|
standardHeaders: "draft-7",
|
|
56
56
|
windowMs: 60 * 1000,
|
|
57
57
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rateLimit.js","sourceRoot":"","sources":["../../src/server/rateLimit.ts"],"names":[],"mappings":"AAyBA,OAAO,SAAS,EAAE,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAE/D;;;;;;;GAOG;AACH,MAAM,OAAO,GAAG,CAAC,GAAY,EAAU,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;AAEvE;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,SAAS,CAAC;IACnC,YAAY,EAAE,OAAO;IACrB,aAAa,EAAE,KAAK;IACpB,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"rateLimit.js","sourceRoot":"","sources":["../../src/server/rateLimit.ts"],"names":[],"mappings":"AAyBA,OAAO,SAAS,EAAE,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAE/D;;;;;;;GAOG;AACH,MAAM,OAAO,GAAG,CAAC,GAAY,EAAU,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;AAEvE;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,SAAS,CAAC;IACnC,YAAY,EAAE,OAAO;IACrB,aAAa,EAAE,KAAK;IACpB,KAAK,EAAE,IAAI;IACX,eAAe,EAAE,SAAS;IAC1B,QAAQ,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI;CAC3B,CAAC,CAAC;AAEH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,SAAS,CAAC;IACjC,YAAY,EAAE,OAAO;IACrB,aAAa,EAAE,KAAK;IACpB,KAAK,EAAE,EAAE;IACT,sBAAsB,EAAE,IAAI;IAC5B,eAAe,EAAE,SAAS;IAC1B,QAAQ,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI;CAC3B,CAAC,CAAC;AAEH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,SAAS,CAAC;IACnC,YAAY,EAAE,OAAO;IACrB,aAAa,EAAE,KAAK;IACpB,KAAK,EAAE,GAAG;IACV,eAAe,EAAE,SAAS;IAC1B,QAAQ,EAAE,EAAE,GAAG,IAAI;CACtB,CAAC,CAAC"}
|
package/dist/server/user.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
import type { Database } from "../Database.ts";
|
|
2
|
-
import type winston from "winston";
|
|
3
2
|
import { TokenScopes } from "@vex-chat/types";
|
|
4
|
-
export declare const getUserRouter: (db: Database,
|
|
3
|
+
export declare const getUserRouter: (db: Database, tokenValidator: (key: string, scope: TokenScopes) => boolean) => import("express-serve-static-core").Router;
|
package/dist/server/user.js
CHANGED
|
@@ -6,7 +6,7 @@ import { stringify } from "uuid";
|
|
|
6
6
|
import { msgpack } from "../utils/msgpack.js";
|
|
7
7
|
import { censorUser, getParam, getUser } from "./utils.js";
|
|
8
8
|
import { protect } from "./index.js";
|
|
9
|
-
export const getUserRouter = (db,
|
|
9
|
+
export const getUserRouter = (db, tokenValidator) => {
|
|
10
10
|
const router = express.Router();
|
|
11
11
|
router.get("/:id", protect, async (req, res) => {
|
|
12
12
|
const user = await db.retrieveUser(getParam(req, "id"));
|
|
@@ -69,7 +69,6 @@ export const getUserRouter = (db, log, tokenValidator) => {
|
|
|
69
69
|
const deviceData = parsed.data;
|
|
70
70
|
const token = xSignOpen(XUtils.decodeHex(deviceData.signed), XUtils.decodeHex(deviceData.signKey));
|
|
71
71
|
if (!token) {
|
|
72
|
-
log.warn("Invalid signature on token.");
|
|
73
72
|
res.sendStatus(400);
|
|
74
73
|
return;
|
|
75
74
|
}
|
|
@@ -78,9 +77,8 @@ export const getUserRouter = (db, log, tokenValidator) => {
|
|
|
78
77
|
const device = await db.createDevice(userDetails.userID, deviceData);
|
|
79
78
|
res.send(msgpack.encode(device));
|
|
80
79
|
}
|
|
81
|
-
catch (
|
|
82
|
-
|
|
83
|
-
// failed registration due to signkey being taken
|
|
80
|
+
catch (_err) {
|
|
81
|
+
// signkey already taken
|
|
84
82
|
res.sendStatus(470);
|
|
85
83
|
return;
|
|
86
84
|
}
|
package/dist/server/user.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.js","sourceRoot":"","sources":["../../src/server/user.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"user.js","sourceRoot":"","sources":["../../src/server/user.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Returns the JWT signing secret.
|
|
2
|
+
* Returns the dedicated JWT HMAC signing secret.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* This MUST be a separate key from SPK (the Ed25519 server signing key)
|
|
5
|
+
* so that compromise of one does not affect the other.
|
|
6
6
|
*/
|
|
7
7
|
export declare function getJwtSecret(): string;
|
package/dist/utils/jwtSecret.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Returns the JWT signing secret.
|
|
2
|
+
* Returns the dedicated JWT HMAC signing secret.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* This MUST be a separate key from SPK (the Ed25519 server signing key)
|
|
5
|
+
* so that compromise of one does not affect the other.
|
|
6
6
|
*/
|
|
7
7
|
export function getJwtSecret() {
|
|
8
|
-
const secret = process.env["JWT_SECRET"]
|
|
8
|
+
const secret = process.env["JWT_SECRET"];
|
|
9
9
|
if (!secret) {
|
|
10
|
-
throw new Error("
|
|
11
|
-
"Set JWT_SECRET (preferred) or SPK in your environment.");
|
|
10
|
+
throw new Error("JWT_SECRET is not set. Generate one with: node scripts/gen-spk.js");
|
|
12
11
|
}
|
|
13
12
|
return secret;
|
|
14
13
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jwtSecret.js","sourceRoot":"","sources":["../../src/utils/jwtSecret.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,YAAY;IACxB,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"jwtSecret.js","sourceRoot":"","sources":["../../src/utils/jwtSecret.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,YAAY;IACxB,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACX,mEAAmE,CACtE,CAAC;IACN,CAAC;IACD,OAAO,MAAM,CAAC;AAClB,CAAC"}
|
package/dist/utils/loadEnv.js
CHANGED
|
@@ -2,7 +2,7 @@ import { config } from "dotenv";
|
|
|
2
2
|
/* Populate process.env with vars from .env and verify required vars are present. */
|
|
3
3
|
export function loadEnv() {
|
|
4
4
|
config();
|
|
5
|
-
const requiredEnvVars = ["
|
|
5
|
+
const requiredEnvVars = ["DB_TYPE", "JWT_SECRET", "SPK"];
|
|
6
6
|
for (const required of requiredEnvVars) {
|
|
7
7
|
if (process.env[required] === undefined) {
|
|
8
8
|
process.stderr.write(`Required environment variable '${required}' is not set. Please consult the README.\n`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loadEnv.js","sourceRoot":"","sources":["../../src/utils/loadEnv.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,oFAAoF;AACpF,MAAM,UAAU,OAAO;IACnB,MAAM,EAAE,CAAC;IACT,MAAM,eAAe,GAAa,CAAC,
|
|
1
|
+
{"version":3,"file":"loadEnv.js","sourceRoot":"","sources":["../../src/utils/loadEnv.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,oFAAoF;AACpF,MAAM,UAAU,OAAO;IACnB,MAAM,EAAE,CAAC;IACT,MAAM,eAAe,GAAa,CAAC,SAAS,EAAE,YAAY,EAAE,KAAK,CAAC,CAAC;IACnE,KAAK,MAAM,QAAQ,IAAI,eAAe,EAAE,CAAC;QACrC,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,SAAS,EAAE,CAAC;YACtC,OAAO,CAAC,MAAM,CAAC,KAAK,CAChB,kCAAkC,QAAQ,4CAA4C,CACzF,CAAC;YACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACpB,CAAC;IACL,CAAC;AACL,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vex-chat/spire",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Vex server implementation in NodeJS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"format:check": "prettier --check .",
|
|
29
29
|
"lint": "eslint src/",
|
|
30
30
|
"lint:fix": "eslint src/ --fix",
|
|
31
|
-
"prepare": "husky",
|
|
31
|
+
"prepare": "husky || true",
|
|
32
32
|
"test": "vitest run",
|
|
33
33
|
"gen-spk": "node scripts/gen-spk.js",
|
|
34
34
|
"docs": "node ./scripts/generate-docs.js",
|
|
@@ -72,6 +72,9 @@
|
|
|
72
72
|
"*.ts": [
|
|
73
73
|
"eslint --fix",
|
|
74
74
|
"prettier --write"
|
|
75
|
+
],
|
|
76
|
+
"*.{yml,yaml}": [
|
|
77
|
+
"prettier --write"
|
|
75
78
|
]
|
|
76
79
|
},
|
|
77
80
|
"overrides": {
|
|
@@ -85,6 +88,7 @@
|
|
|
85
88
|
"@scalar/express-api-reference": "0.9.7",
|
|
86
89
|
"@vex-chat/crypto": "2.0.0",
|
|
87
90
|
"@vex-chat/types": "2.0.0",
|
|
91
|
+
"argon2": "0.44.0",
|
|
88
92
|
"better-sqlite3": "11.10.0",
|
|
89
93
|
"cors": "2.8.6",
|
|
90
94
|
"dotenv": "17.4.1",
|
|
@@ -98,10 +102,8 @@
|
|
|
98
102
|
"msgpackr": "1.11.8",
|
|
99
103
|
"multer": "2.1.1",
|
|
100
104
|
"parse-duration": "2.1.6",
|
|
101
|
-
"picocolors": "1.1.1",
|
|
102
105
|
"tweetnacl": "1.0.3",
|
|
103
106
|
"uuid": "8.3.2",
|
|
104
|
-
"winston": "3.19.0",
|
|
105
107
|
"ws": "8.20.0",
|
|
106
108
|
"zod": "4.3.6"
|
|
107
109
|
}
|
package/src/ClientManager.ts
CHANGED
|
@@ -11,7 +11,6 @@ import type {
|
|
|
11
11
|
User,
|
|
12
12
|
UserRecord,
|
|
13
13
|
} from "@vex-chat/types";
|
|
14
|
-
import type winston from "winston";
|
|
15
14
|
import type WebSocket from "ws";
|
|
16
15
|
|
|
17
16
|
import { EventEmitter } from "events";
|
|
@@ -21,11 +20,9 @@ import { xConcat, XUtils } from "@vex-chat/crypto";
|
|
|
21
20
|
import { xSignOpen } from "@vex-chat/crypto";
|
|
22
21
|
import { MailWSSchema, SocketAuthErrors } from "@vex-chat/types";
|
|
23
22
|
|
|
24
|
-
import pc from "picocolors";
|
|
25
23
|
import { parse as uuidParse, validate as uuidValidate } from "uuid";
|
|
26
24
|
|
|
27
|
-
import {
|
|
28
|
-
import { createLogger } from "./utils/createLogger.ts";
|
|
25
|
+
import { TOKEN_EXPIRY } from "./Spire.ts";
|
|
29
26
|
import { createUint8UUID } from "./utils/createUint8UUID.ts";
|
|
30
27
|
import { msgpack } from "./utils/msgpack.ts";
|
|
31
28
|
|
|
@@ -50,7 +47,6 @@ export class ClientManager extends EventEmitter {
|
|
|
50
47
|
private db: Database;
|
|
51
48
|
private device: Device | null;
|
|
52
49
|
private failed: boolean = false;
|
|
53
|
-
private log: winston.Logger;
|
|
54
50
|
private notify: (
|
|
55
51
|
userID: string,
|
|
56
52
|
event: string,
|
|
@@ -66,7 +62,6 @@ export class ClientManager extends EventEmitter {
|
|
|
66
62
|
db: Database,
|
|
67
63
|
notify: (userID: string, event: string, transmissionID: string) => void,
|
|
68
64
|
userDetails: User,
|
|
69
|
-
options?: SpireOptions,
|
|
70
65
|
) {
|
|
71
66
|
super();
|
|
72
67
|
this.conn = ws;
|
|
@@ -75,7 +70,6 @@ export class ClientManager extends EventEmitter {
|
|
|
75
70
|
this.userDetails = userDetails;
|
|
76
71
|
this.device = null;
|
|
77
72
|
this.notify = notify;
|
|
78
|
-
this.log = createLogger("client-manager", options?.logLevel || "error");
|
|
79
73
|
|
|
80
74
|
this.initListeners();
|
|
81
75
|
this.challenge();
|
|
@@ -96,28 +90,11 @@ export class ClientManager extends EventEmitter {
|
|
|
96
90
|
}
|
|
97
91
|
|
|
98
92
|
public send(msg: BaseMsg, header?: Uint8Array) {
|
|
99
|
-
if (header) {
|
|
100
|
-
this.log.debug(pc.bold(pc.red("OUTH")), header.toString());
|
|
101
|
-
} else {
|
|
102
|
-
this.log.debug(pc.bold(pc.red("OUTH")), emptyHeader.toString());
|
|
103
|
-
}
|
|
104
|
-
|
|
105
93
|
const packedMessage = packMessage(msg, header);
|
|
106
|
-
|
|
107
|
-
this.log.info(
|
|
108
|
-
pc.bold("⟶ ") +
|
|
109
|
-
responseColor(msg.type.toUpperCase()) +
|
|
110
|
-
" " +
|
|
111
|
-
this.toString() +
|
|
112
|
-
" " +
|
|
113
|
-
pc.yellow(Buffer.byteLength(packedMessage)),
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
this.log.debug(pc.bold(pc.red("OUT")), msg);
|
|
117
94
|
try {
|
|
118
95
|
this.conn.send(packedMessage);
|
|
119
|
-
} catch (
|
|
120
|
-
|
|
96
|
+
} catch (_err: unknown) {
|
|
97
|
+
// debugger: WS send failed
|
|
121
98
|
this.fail();
|
|
122
99
|
}
|
|
123
100
|
}
|
|
@@ -150,7 +127,6 @@ export class ClientManager extends EventEmitter {
|
|
|
150
127
|
if (this.failed) {
|
|
151
128
|
return;
|
|
152
129
|
}
|
|
153
|
-
this.log.warn("Connection closed.");
|
|
154
130
|
this.conn.close();
|
|
155
131
|
this.failed = true;
|
|
156
132
|
this.emit("fail");
|
|
@@ -173,12 +149,11 @@ export class ClientManager extends EventEmitter {
|
|
|
173
149
|
this.fail();
|
|
174
150
|
});
|
|
175
151
|
this.conn.on("message", (message: Buffer) => {
|
|
176
|
-
const [header, msg] = unpackMessage(message);
|
|
177
152
|
const size = Buffer.byteLength(message);
|
|
178
153
|
|
|
179
154
|
if (size > MAX_MSG_SIZE) {
|
|
180
155
|
this.sendErr(
|
|
181
|
-
|
|
156
|
+
"00000000-0000-0000-0000-000000000000",
|
|
182
157
|
"Message is too big. Received size " +
|
|
183
158
|
String(size) +
|
|
184
159
|
" while max size is " +
|
|
@@ -187,16 +162,7 @@ export class ClientManager extends EventEmitter {
|
|
|
187
162
|
return;
|
|
188
163
|
}
|
|
189
164
|
|
|
190
|
-
|
|
191
|
-
pc.bold("⟵ ") +
|
|
192
|
-
pc.bold(msg.type.toUpperCase()) +
|
|
193
|
-
" " +
|
|
194
|
-
this.toString() +
|
|
195
|
-
" " +
|
|
196
|
-
pc.yellow(String(size)),
|
|
197
|
-
);
|
|
198
|
-
this.log.debug(pc.bold(pc.red("INH")), header.toString());
|
|
199
|
-
this.log.debug(pc.bold(pc.red("IN")), msg);
|
|
165
|
+
const [header, msg] = unpackMessage(message);
|
|
200
166
|
|
|
201
167
|
if (!msg.type) {
|
|
202
168
|
this.sendErr(msg.transmissionID, "Message type is required.");
|
|
@@ -238,7 +204,6 @@ export class ClientManager extends EventEmitter {
|
|
|
238
204
|
void this.verifyResponse(msg as RespMsg);
|
|
239
205
|
break;
|
|
240
206
|
default:
|
|
241
|
-
this.log.info("unsupported message %s", msg.type);
|
|
242
207
|
break;
|
|
243
208
|
}
|
|
244
209
|
});
|
|
@@ -266,8 +231,6 @@ export class ClientManager extends EventEmitter {
|
|
|
266
231
|
this.getDevice().deviceID,
|
|
267
232
|
this.getUser().userID,
|
|
268
233
|
);
|
|
269
|
-
this.log.info("Received mail for " + mail.recipient);
|
|
270
|
-
|
|
271
234
|
const deviceDetails = await this.db.retrieveDevice(
|
|
272
235
|
mail.recipient,
|
|
273
236
|
);
|
|
@@ -288,13 +251,12 @@ export class ClientManager extends EventEmitter {
|
|
|
288
251
|
mail.recipient,
|
|
289
252
|
);
|
|
290
253
|
} catch (err: unknown) {
|
|
291
|
-
this.log.error(String(err));
|
|
292
254
|
this.sendErr(msg.transmissionID, String(err));
|
|
293
255
|
}
|
|
294
256
|
}
|
|
295
257
|
break;
|
|
296
258
|
default:
|
|
297
|
-
|
|
259
|
+
break;
|
|
298
260
|
}
|
|
299
261
|
}
|
|
300
262
|
|
|
@@ -384,7 +346,6 @@ export class ClientManager extends EventEmitter {
|
|
|
384
346
|
}
|
|
385
347
|
}
|
|
386
348
|
if (!message) {
|
|
387
|
-
this.log.warn("Signature verification failed!");
|
|
388
349
|
this.sendAuthError(SocketAuthErrors.BadSignature);
|
|
389
350
|
this.fail();
|
|
390
351
|
return;
|
|
@@ -394,11 +355,9 @@ export class ClientManager extends EventEmitter {
|
|
|
394
355
|
this.user = user;
|
|
395
356
|
this.authorize(msg.transmissionID);
|
|
396
357
|
} else {
|
|
397
|
-
this.log.warn("Token is bad!");
|
|
398
358
|
this.sendAuthError(SocketAuthErrors.InvalidToken);
|
|
399
359
|
}
|
|
400
360
|
} else {
|
|
401
|
-
this.log.info("User is not registered.");
|
|
402
361
|
this.sendAuthError(SocketAuthErrors.UserNotRegistered);
|
|
403
362
|
|
|
404
363
|
this.fail();
|
|
@@ -421,14 +380,3 @@ function unpackMessage(msg: Buffer): [Uint8Array, BaseMsg] {
|
|
|
421
380
|
|
|
422
381
|
return [msgh, msgb];
|
|
423
382
|
}
|
|
424
|
-
|
|
425
|
-
const responseColor = (status: string): string => {
|
|
426
|
-
switch (status) {
|
|
427
|
-
case "ERROR":
|
|
428
|
-
return pc.bold(pc.red(status));
|
|
429
|
-
case "SUCCESS":
|
|
430
|
-
return pc.bold(pc.green(status));
|
|
431
|
-
default:
|
|
432
|
-
return status;
|
|
433
|
-
}
|
|
434
|
-
};
|
package/src/Database.ts
CHANGED
|
@@ -18,7 +18,6 @@ import type {
|
|
|
18
18
|
UserRecord,
|
|
19
19
|
} from "@vex-chat/types";
|
|
20
20
|
import type { Migration, MigrationProvider } from "kysely";
|
|
21
|
-
import type winston from "winston";
|
|
22
21
|
|
|
23
22
|
import { EventEmitter } from "events";
|
|
24
23
|
import { pbkdf2Sync } from "node:crypto";
|
|
@@ -26,9 +25,11 @@ import * as fs from "node:fs/promises";
|
|
|
26
25
|
import path from "node:path";
|
|
27
26
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
28
27
|
|
|
29
|
-
import {
|
|
28
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
30
29
|
import { MailType } from "@vex-chat/types";
|
|
31
30
|
|
|
31
|
+
import argon2 from "argon2";
|
|
32
|
+
|
|
32
33
|
/**
|
|
33
34
|
* Narrow a plain integer from the `mailType` SQL column to the
|
|
34
35
|
* `MailType` union (0 = initial, 1 = subsequent). Throws if the
|
|
@@ -46,8 +47,6 @@ import BetterSqlite3 from "better-sqlite3";
|
|
|
46
47
|
import { Kysely, Migrator, sql, SqliteDialect } from "kysely";
|
|
47
48
|
import { stringify as uuidStringify, validate as uuidValidate } from "uuid";
|
|
48
49
|
|
|
49
|
-
import { createLogger } from "./utils/createLogger.ts";
|
|
50
|
-
|
|
51
50
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
52
51
|
const migrationFolder = path.join(__dirname, "migrations");
|
|
53
52
|
|
|
@@ -104,21 +103,25 @@ function isMigration(mod: unknown): mod is Migration {
|
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
const pubkeyRegex = /[0-9a-f]{64}/;
|
|
107
|
-
|
|
106
|
+
|
|
107
|
+
/** Legacy iteration count kept only for verifying old PBKDF2 hashes. */
|
|
108
|
+
const PBKDF2_ITERATIONS = 1000;
|
|
108
109
|
|
|
109
110
|
// ── Row-to-interface converters ─────────────────────────────────────────
|
|
110
111
|
// SQLite stores booleans as integers and dates as strings, but the
|
|
111
112
|
// @vex-chat/types interfaces expect boolean / Date.
|
|
112
113
|
|
|
114
|
+
/** Internal record that includes the hash algorithm discriminator. */
|
|
115
|
+
export interface InternalUserRecord extends UserRecord {
|
|
116
|
+
hashAlgo: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
113
119
|
export class Database extends EventEmitter {
|
|
114
120
|
private db: Kysely<ServerDatabase>;
|
|
115
|
-
private log: winston.Logger;
|
|
116
121
|
|
|
117
122
|
constructor(options?: SpireOptions) {
|
|
118
123
|
super();
|
|
119
124
|
|
|
120
|
-
this.log = createLogger("spire-db", options?.logLevel || "error");
|
|
121
|
-
|
|
122
125
|
const dbType = options?.dbType || "sqlite3";
|
|
123
126
|
|
|
124
127
|
let filename: string;
|
|
@@ -151,7 +154,6 @@ export class Database extends EventEmitter {
|
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
public async close(): Promise<void> {
|
|
154
|
-
this.log.info("Closing database.");
|
|
155
157
|
await this.db.destroy();
|
|
156
158
|
}
|
|
157
159
|
|
|
@@ -280,13 +282,12 @@ export class Database extends EventEmitter {
|
|
|
280
282
|
regPayload: RegistrationPayload,
|
|
281
283
|
): Promise<[null | UserRecord, Error | null]> {
|
|
282
284
|
try {
|
|
283
|
-
const
|
|
284
|
-
const passwordHash = hashPassword(regPayload.password, salt);
|
|
285
|
+
const passwordHash = await hashPasswordArgon2(regPayload.password);
|
|
285
286
|
|
|
286
287
|
const user: UserRecord = {
|
|
287
288
|
lastSeen: new Date().toISOString(),
|
|
288
|
-
passwordHash
|
|
289
|
-
passwordSalt:
|
|
289
|
+
passwordHash,
|
|
290
|
+
passwordSalt: "",
|
|
290
291
|
userID: uuidStringify(regKey),
|
|
291
292
|
username: regPayload.username,
|
|
292
293
|
};
|
|
@@ -295,6 +296,7 @@ export class Database extends EventEmitter {
|
|
|
295
296
|
.insertInto("users")
|
|
296
297
|
.values({
|
|
297
298
|
...user,
|
|
299
|
+
hashAlgo: "argon2id",
|
|
298
300
|
lastSeen: user.lastSeen,
|
|
299
301
|
})
|
|
300
302
|
.execute();
|
|
@@ -489,8 +491,8 @@ export class Database extends EventEmitter {
|
|
|
489
491
|
try {
|
|
490
492
|
await sql`select 1 as ok`.execute(this.db);
|
|
491
493
|
return true;
|
|
492
|
-
} catch (
|
|
493
|
-
|
|
494
|
+
} catch (_err: unknown) {
|
|
495
|
+
// debugger: health check failed
|
|
494
496
|
return false;
|
|
495
497
|
}
|
|
496
498
|
}
|
|
@@ -511,6 +513,21 @@ export class Database extends EventEmitter {
|
|
|
511
513
|
.execute();
|
|
512
514
|
}
|
|
513
515
|
|
|
516
|
+
public async rehashPassword(
|
|
517
|
+
userID: string,
|
|
518
|
+
newHash: string,
|
|
519
|
+
): Promise<void> {
|
|
520
|
+
await this.db
|
|
521
|
+
.updateTable("users")
|
|
522
|
+
.set({
|
|
523
|
+
hashAlgo: "argon2id",
|
|
524
|
+
passwordHash: newHash,
|
|
525
|
+
passwordSalt: "",
|
|
526
|
+
})
|
|
527
|
+
.where("userID", "=", userID)
|
|
528
|
+
.execute();
|
|
529
|
+
}
|
|
530
|
+
|
|
514
531
|
/**
|
|
515
532
|
* Retrives a list of users that should be notified when a specific resourceID
|
|
516
533
|
* experiences changes.
|
|
@@ -763,7 +780,7 @@ export class Database extends EventEmitter {
|
|
|
763
780
|
// the identifier can be username, public key, or userID
|
|
764
781
|
public async retrieveUser(
|
|
765
782
|
userIdentifier: string,
|
|
766
|
-
): Promise<
|
|
783
|
+
): Promise<InternalUserRecord | null> {
|
|
767
784
|
let rows;
|
|
768
785
|
if (uuidValidate(userIdentifier)) {
|
|
769
786
|
rows = await this.db
|
|
@@ -795,7 +812,7 @@ export class Database extends EventEmitter {
|
|
|
795
812
|
return rows.map(toDevice);
|
|
796
813
|
}
|
|
797
814
|
|
|
798
|
-
public async retrieveUsers(): Promise<
|
|
815
|
+
public async retrieveUsers(): Promise<InternalUserRecord[]> {
|
|
799
816
|
const rows = await this.db.selectFrom("users").selectAll().execute();
|
|
800
817
|
return rows.map(toUserRecord);
|
|
801
818
|
}
|
|
@@ -864,6 +881,53 @@ export class Database extends EventEmitter {
|
|
|
864
881
|
}
|
|
865
882
|
}
|
|
866
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Hash a password with Argon2id (new default).
|
|
886
|
+
* Returns the encoded hash string which embeds salt, params, and digest.
|
|
887
|
+
*/
|
|
888
|
+
export async function hashPasswordArgon2(password: string): Promise<string> {
|
|
889
|
+
return argon2.hash(password, {
|
|
890
|
+
memoryCost: 65536,
|
|
891
|
+
parallelism: 4,
|
|
892
|
+
timeCost: 3,
|
|
893
|
+
type: argon2.argon2id,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Verify a password against either Argon2id or legacy PBKDF2 storage.
|
|
899
|
+
* Returns `{ valid, needsRehash }` — callers should rehash on success
|
|
900
|
+
* when `needsRehash` is true.
|
|
901
|
+
*/
|
|
902
|
+
export async function verifyPassword(
|
|
903
|
+
password: string,
|
|
904
|
+
stored: { hashAlgo: string; passwordHash: string; passwordSalt: string },
|
|
905
|
+
): Promise<{ needsRehash: boolean; valid: boolean }> {
|
|
906
|
+
if (stored.hashAlgo === "argon2id") {
|
|
907
|
+
const valid = await argon2.verify(stored.passwordHash, password);
|
|
908
|
+
return { needsRehash: false, valid };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Legacy PBKDF2 path
|
|
912
|
+
const salt = XUtils.decodeHex(stored.passwordSalt);
|
|
913
|
+
const computed = pbkdf2Sync(
|
|
914
|
+
password,
|
|
915
|
+
salt,
|
|
916
|
+
PBKDF2_ITERATIONS,
|
|
917
|
+
32,
|
|
918
|
+
"sha512",
|
|
919
|
+
);
|
|
920
|
+
const storedBuf = XUtils.decodeHex(stored.passwordHash);
|
|
921
|
+
|
|
922
|
+
if (computed.length !== storedBuf.length) {
|
|
923
|
+
return { needsRehash: false, valid: false };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const { timingSafeEqual } = await import("node:crypto");
|
|
927
|
+
const valid = timingSafeEqual(computed, storedBuf);
|
|
928
|
+
return { needsRehash: valid, valid };
|
|
929
|
+
}
|
|
930
|
+
|
|
867
931
|
function toDevice(row: {
|
|
868
932
|
deleted: number;
|
|
869
933
|
deviceID: string;
|
|
@@ -912,14 +976,12 @@ function toServer(row: {
|
|
|
912
976
|
}
|
|
913
977
|
|
|
914
978
|
function toUserRecord(row: {
|
|
979
|
+
hashAlgo: string;
|
|
915
980
|
lastSeen: string;
|
|
916
981
|
passwordHash: string;
|
|
917
982
|
passwordSalt: string;
|
|
918
983
|
userID: string;
|
|
919
984
|
username: string;
|
|
920
|
-
}):
|
|
985
|
+
}): InternalUserRecord {
|
|
921
986
|
return { ...row };
|
|
922
987
|
}
|
|
923
|
-
|
|
924
|
-
export const hashPassword = (password: string, salt: Uint8Array) =>
|
|
925
|
-
pbkdf2Sync(password, salt, ITERATIONS, 32, "sha512");
|