@vex-chat/spire 3.0.0 → 4.0.0
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 +9 -8
- package/dist/Database.d.ts +12 -11
- package/dist/Database.js +161 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -3
- package/dist/Spire.js +176 -92
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +0 -2
- package/dist/migrations/2026-04-06_initial-schema.js +4 -5
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
- package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
- package/dist/migrations/2026-07-14_query-indexes.js +31 -0
- package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
- package/dist/server/avatar.js +30 -4
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.js +35 -12
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.js +157 -41
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +41 -33
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +1 -0
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/{passkeySecondFactor.d.ts → password.d.ts} +1 -1
- package/dist/server/password.js +58 -0
- package/dist/server/password.js.map +1 -0
- package/dist/server/permissions.d.ts +1 -0
- package/dist/server/permissions.js +11 -2
- package/dist/server/permissions.js.map +1 -1
- package/dist/server/rateLimit.d.ts +11 -0
- package/dist/server/rateLimit.js +43 -4
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +53 -17
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +3 -4
- package/dist/types/express.js.map +1 -1
- package/dist/utils/authJwt.d.ts +8 -0
- package/dist/utils/authJwt.js +25 -0
- package/dist/utils/authJwt.js.map +1 -0
- package/dist/utils/loadEnv.js +4 -0
- package/dist/utils/loadEnv.js.map +1 -1
- package/dist/utils/preKeySignature.d.ts +1 -1
- package/dist/utils/preKeySignature.js +7 -11
- package/dist/utils/preKeySignature.js.map +1 -1
- package/package.json +7 -7
- package/src/Database.ts +211 -152
- package/src/Spire.ts +418 -294
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/connectAuth.spec.ts +146 -4
- package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
- package/src/__tests__/passkeyDevices.spec.ts +94 -0
- package/src/__tests__/passkeys.spec.ts +9 -54
- package/src/__tests__/password.spec.ts +223 -0
- package/src/__tests__/permissions.spec.ts +50 -0
- package/src/__tests__/preKeySignature.spec.ts +20 -3
- package/src/db/schema.ts +0 -2
- package/src/migrations/2026-04-06_initial-schema.ts +4 -5
- package/src/migrations/2026-07-14_query-indexes.ts +34 -0
- package/src/server/avatar.ts +29 -4
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +286 -111
- package/src/server/passkey.ts +51 -35
- package/src/server/passkeyDevices.ts +1 -0
- package/src/server/password.ts +96 -0
- package/src/server/permissions.ts +20 -2
- package/src/server/rateLimit.ts +47 -4
- package/src/server/user.ts +58 -25
- package/src/types/express.ts +3 -4
- package/src/utils/authJwt.ts +34 -0
- package/src/utils/loadEnv.ts +4 -0
- package/src/utils/preKeySignature.ts +12 -15
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
- package/dist/server/passkeySecondFactor.js +0 -20
- package/dist/server/passkeySecondFactor.js.map +0 -1
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
- package/src/server/passkeySecondFactor.ts +0 -27
package/src/Spire.ts
CHANGED
|
@@ -31,13 +31,13 @@ import {
|
|
|
31
31
|
xSignKeyPairFromSecretAsync,
|
|
32
32
|
} from "@vex-chat/crypto";
|
|
33
33
|
import {
|
|
34
|
+
ACCOUNT_PASSWORD_MAX_LENGTH,
|
|
34
35
|
MailWSSchema,
|
|
35
36
|
RegistrationPayloadSchema,
|
|
36
37
|
TokenScopes,
|
|
37
38
|
UserSchema,
|
|
38
39
|
} from "@vex-chat/types";
|
|
39
40
|
|
|
40
|
-
import jwt from "jsonwebtoken";
|
|
41
41
|
import morgan from "morgan";
|
|
42
42
|
import { stringify as uuidStringify } from "uuid";
|
|
43
43
|
import { WebSocketServer } from "ws";
|
|
@@ -45,7 +45,13 @@ import { z } from "zod/v4";
|
|
|
45
45
|
|
|
46
46
|
import { CallManager } from "./CallManager.ts";
|
|
47
47
|
import { ClientManager } from "./ClientManager.ts";
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
Database,
|
|
50
|
+
hashPasswordArgon2,
|
|
51
|
+
MAX_ACTIVE_DEVICES_PER_USER,
|
|
52
|
+
validateAccountPassword,
|
|
53
|
+
verifyPassword,
|
|
54
|
+
} from "./Database.ts";
|
|
49
55
|
import { resolveIceServersFromEnv } from "./IceServers.ts";
|
|
50
56
|
import { NotificationService } from "./NotificationService.ts";
|
|
51
57
|
import { initApp, protect } from "./server/index.ts";
|
|
@@ -53,21 +59,23 @@ import {
|
|
|
53
59
|
MailIngressValidationError,
|
|
54
60
|
validateMailIngress,
|
|
55
61
|
} from "./server/mailIngress.ts";
|
|
56
|
-
import {
|
|
57
|
-
|
|
62
|
+
import {
|
|
63
|
+
accountAuthLimiter,
|
|
64
|
+
authLimiter,
|
|
65
|
+
devApiKeySkipsRateLimits,
|
|
66
|
+
} from "./server/rateLimit.ts";
|
|
58
67
|
import { createPendingDeviceEnrollmentRequest } from "./server/user.ts";
|
|
59
68
|
import { censorUser, getParam, getUser } from "./server/utils.ts";
|
|
60
69
|
import { resolveSpireListenPort } from "./spireListenPort.ts";
|
|
61
|
-
import {
|
|
70
|
+
import { signAuthJwt, verifyAuthJwt } from "./utils/authJwt.ts";
|
|
62
71
|
import { msgpack } from "./utils/msgpack.ts";
|
|
72
|
+
import { verifyDevicePayloadPreKeySignature } from "./utils/preKeySignature.ts";
|
|
63
73
|
import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
|
|
64
74
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// expiry of regkeys = 24hr
|
|
75
|
+
// One-use action tokens expire after ten minutes.
|
|
68
76
|
export const TOKEN_EXPIRY = 1000 * 60 * 10;
|
|
69
|
-
export const JWT_EXPIRY = "
|
|
70
|
-
export const DEVICE_AUTH_JWT_EXPIRY = "
|
|
77
|
+
export const JWT_EXPIRY = "1h";
|
|
78
|
+
export const DEVICE_AUTH_JWT_EXPIRY = "1h";
|
|
71
79
|
/**
|
|
72
80
|
* Passkey-scoped JWTs grant destructive admin powers (delete a
|
|
73
81
|
* device, recover a device enrollment) without further user
|
|
@@ -79,6 +87,8 @@ const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
|
|
|
79
87
|
|
|
80
88
|
// 3-19 chars long
|
|
81
89
|
const usernameRegex = /^(\w{3,19})$/;
|
|
90
|
+
const uuidRegex =
|
|
91
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
82
92
|
|
|
83
93
|
// ── Zod schemas for trust-boundary validation ──────────────────────────
|
|
84
94
|
const wsAuthMsg = z.object({
|
|
@@ -89,26 +99,65 @@ const wsAuthMsg = z.object({
|
|
|
89
99
|
const jwtPayload = z.object({
|
|
90
100
|
bearerToken: z.string().optional(),
|
|
91
101
|
exp: z.number().optional(),
|
|
102
|
+
scope: z.literal("user"),
|
|
92
103
|
user: UserSchema,
|
|
93
104
|
});
|
|
94
105
|
|
|
95
106
|
const authPayload = z.object({
|
|
96
|
-
password: z.string().min(1),
|
|
97
|
-
username: z.string().min(
|
|
107
|
+
password: z.string().min(1).max(ACCOUNT_PASSWORD_MAX_LENGTH),
|
|
108
|
+
username: z.string().trim().min(3).max(19).regex(usernameRegex),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const boundedRegistrationPayload = z.object({
|
|
112
|
+
deviceName: z.string().trim().min(1).max(100),
|
|
113
|
+
intent: z.enum(["create-account", "enroll-device"]),
|
|
114
|
+
password: z.string().max(ACCOUNT_PASSWORD_MAX_LENGTH).optional(),
|
|
115
|
+
preKey: z
|
|
116
|
+
.string()
|
|
117
|
+
.min(2)
|
|
118
|
+
.max(8192)
|
|
119
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
120
|
+
preKeyIndex: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
121
|
+
preKeySignature: z
|
|
122
|
+
.string()
|
|
123
|
+
.min(2)
|
|
124
|
+
.max(16_384)
|
|
125
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
126
|
+
signed: z
|
|
127
|
+
.string()
|
|
128
|
+
.min(2)
|
|
129
|
+
.max(8192)
|
|
130
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
131
|
+
signKey: z
|
|
132
|
+
.string()
|
|
133
|
+
.min(2)
|
|
134
|
+
.max(8192)
|
|
135
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
136
|
+
username: z.string().trim().min(3).max(19).regex(usernameRegex).optional(),
|
|
98
137
|
});
|
|
99
138
|
|
|
100
139
|
const deviceAuthPayload = z.object({
|
|
101
|
-
deviceID: z.string().
|
|
102
|
-
signKey: z
|
|
140
|
+
deviceID: z.string().regex(uuidRegex),
|
|
141
|
+
signKey: z
|
|
142
|
+
.string()
|
|
143
|
+
.min(64)
|
|
144
|
+
.max(8192)
|
|
145
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
103
146
|
});
|
|
104
147
|
|
|
105
148
|
const deviceVerifyPayload = z.object({
|
|
106
|
-
challengeID: z.string().
|
|
107
|
-
signed: z
|
|
149
|
+
challengeID: z.string().regex(uuidRegex),
|
|
150
|
+
signed: z
|
|
151
|
+
.string()
|
|
152
|
+
.min(2)
|
|
153
|
+
.max(16_384)
|
|
154
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
108
155
|
});
|
|
109
156
|
|
|
110
157
|
const mailPostPayload = z.object({
|
|
111
|
-
header: z.custom<Uint8Array>(
|
|
158
|
+
header: z.custom<Uint8Array>(
|
|
159
|
+
(val) => val instanceof Uint8Array && val.byteLength === 32,
|
|
160
|
+
),
|
|
112
161
|
mail: MailWSSchema,
|
|
113
162
|
});
|
|
114
163
|
const MAIL_BATCH_MAX_ITEMS = 256;
|
|
@@ -134,9 +183,9 @@ interface ValidatedMailBatchEntry {
|
|
|
134
183
|
|
|
135
184
|
const notificationSubscribePayload = z.object({
|
|
136
185
|
channel: z.literal("expo"),
|
|
137
|
-
events: z.array(z.string().min(1)).default(["mail"]),
|
|
186
|
+
events: z.array(z.string().min(1).max(64)).max(32).default(["mail"]),
|
|
138
187
|
platform: z.enum(["android", "ios", "web"]).optional(),
|
|
139
|
-
token: z.string().min(1),
|
|
188
|
+
token: z.string().min(1).max(4096),
|
|
140
189
|
});
|
|
141
190
|
|
|
142
191
|
const directories = ["files", "avatars", "emoji"];
|
|
@@ -197,6 +246,19 @@ export interface SpireOptions {
|
|
|
197
246
|
dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
|
|
198
247
|
}
|
|
199
248
|
|
|
249
|
+
export function resolveTrustProxyHops(value: string | undefined): number {
|
|
250
|
+
if (value === undefined || value.trim() === "") {
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
const hops = Number(value);
|
|
254
|
+
if (!Number.isInteger(hops) || hops < 0 || hops > 10) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
"SPIRE_TRUST_PROXY_HOPS must be an integer from 0 to 10.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return hops;
|
|
260
|
+
}
|
|
261
|
+
|
|
200
262
|
/**
|
|
201
263
|
* Masks identifying material in the access-log URL: hyphenated UUIDs, and
|
|
202
264
|
* `/device/...` path segments that hold public-key material (32B ed25519 hex, or
|
|
@@ -216,7 +278,7 @@ function redactAccessLogUrl(url: string): string {
|
|
|
216
278
|
let pendingFipsKeyPair: KeyPair | null = null;
|
|
217
279
|
|
|
218
280
|
export class Spire extends EventEmitter {
|
|
219
|
-
private actionTokens
|
|
281
|
+
private actionTokens = new Map<string, ActionToken>();
|
|
220
282
|
private api = express();
|
|
221
283
|
private calls: CallManager;
|
|
222
284
|
private clients: ClientManager[] = [];
|
|
@@ -261,12 +323,13 @@ export class Spire extends EventEmitter {
|
|
|
261
323
|
this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
|
|
262
324
|
}
|
|
263
325
|
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
326
|
+
// Proxy trust must match the deployment topology. Defaulting to zero
|
|
327
|
+
// prevents direct deployments from accepting attacker-supplied
|
|
328
|
+
// X-Forwarded-For values and bypassing IP rate limits.
|
|
329
|
+
this.api.set(
|
|
330
|
+
"trust proxy",
|
|
331
|
+
resolveTrustProxyHops(process.env["SPIRE_TRUST_PROXY_HOPS"]),
|
|
332
|
+
);
|
|
270
333
|
this.api.disable("etag");
|
|
271
334
|
|
|
272
335
|
this.db = new Database(options);
|
|
@@ -362,20 +425,18 @@ export class Spire extends EventEmitter {
|
|
|
362
425
|
}
|
|
363
426
|
|
|
364
427
|
private createActionToken(scope: TokenScopes): ActionToken {
|
|
428
|
+
this.pruneActionTokens();
|
|
365
429
|
const token: ActionToken = {
|
|
366
430
|
key: crypto.randomUUID(),
|
|
367
431
|
scope,
|
|
368
432
|
time: new Date().toISOString(),
|
|
369
433
|
};
|
|
370
|
-
this.actionTokens.
|
|
434
|
+
this.actionTokens.set(token.key, token);
|
|
371
435
|
return token;
|
|
372
436
|
}
|
|
373
437
|
|
|
374
438
|
private deleteActionToken(key: ActionToken) {
|
|
375
|
-
|
|
376
|
-
if (idx !== -1) {
|
|
377
|
-
this.actionTokens.splice(idx, 1);
|
|
378
|
-
}
|
|
439
|
+
this.actionTokens.delete(key.key);
|
|
379
440
|
}
|
|
380
441
|
|
|
381
442
|
private disconnectDevices(deviceIDs: string[]): void {
|
|
@@ -457,10 +518,7 @@ export class Spire extends EventEmitter {
|
|
|
457
518
|
JSON.stringify(authResult.error.issues),
|
|
458
519
|
);
|
|
459
520
|
}
|
|
460
|
-
const result =
|
|
461
|
-
authResult.data.token,
|
|
462
|
-
getJwtSecret(),
|
|
463
|
-
);
|
|
521
|
+
const result = verifyAuthJwt(authResult.data.token);
|
|
464
522
|
const jwtResult = jwtPayload.safeParse(result);
|
|
465
523
|
if (!jwtResult.success) {
|
|
466
524
|
throw new Error(
|
|
@@ -692,9 +750,10 @@ export class Spire extends EventEmitter {
|
|
|
692
750
|
}
|
|
693
751
|
});
|
|
694
752
|
|
|
695
|
-
this.api.post("/goodbye", protect, (
|
|
696
|
-
|
|
697
|
-
|
|
753
|
+
this.api.post("/goodbye", protect, (_req, res) => {
|
|
754
|
+
// Access tokens are stateless. Clients clear them locally; this
|
|
755
|
+
// endpoint exists only as a clean session boundary for callers.
|
|
756
|
+
res.sendStatus(204);
|
|
698
757
|
});
|
|
699
758
|
|
|
700
759
|
// ── Device-key auth ──────────────────────────────────────────
|
|
@@ -797,27 +856,10 @@ export class Spire extends EventEmitter {
|
|
|
797
856
|
.send({ error: "Device owner not found." });
|
|
798
857
|
}
|
|
799
858
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
user
|
|
803
|
-
|
|
804
|
-
"Passkey verification does not match this device.",
|
|
805
|
-
);
|
|
806
|
-
if (passkeyError) {
|
|
807
|
-
const body: { error: string; username?: string } = {
|
|
808
|
-
error: passkeyError,
|
|
809
|
-
};
|
|
810
|
-
if (passkeyError === "Passkey verification required.") {
|
|
811
|
-
body.username = user.username;
|
|
812
|
-
}
|
|
813
|
-
return res.status(403).send(body);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Issue short-lived JWT (1 hour, not 7 days)
|
|
817
|
-
const token = jwt.sign(
|
|
818
|
-
{ user: censorUser(user) },
|
|
819
|
-
getJwtSecret(),
|
|
820
|
-
{ expiresIn: DEVICE_AUTH_JWT_EXPIRY },
|
|
859
|
+
// Device proof restores the same bounded account session as password login.
|
|
860
|
+
const token = signAuthJwt(
|
|
861
|
+
{ scope: "user", user: censorUser(user) },
|
|
862
|
+
DEVICE_AUTH_JWT_EXPIRY,
|
|
821
863
|
);
|
|
822
864
|
return res.send(
|
|
823
865
|
msgpack.encode({ token, user: censorUser(user) }),
|
|
@@ -1077,247 +1119,336 @@ export class Spire extends EventEmitter {
|
|
|
1077
1119
|
},
|
|
1078
1120
|
);
|
|
1079
1121
|
|
|
1080
|
-
this.api.post(
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
try {
|
|
1091
|
-
const userEntry = await this.db.retrieveUser(username);
|
|
1092
|
-
if (!userEntry) {
|
|
1093
|
-
res.sendStatus(401);
|
|
1094
|
-
return;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
const { needsRehash, valid } = await verifyPassword(
|
|
1098
|
-
password,
|
|
1099
|
-
userEntry,
|
|
1100
|
-
);
|
|
1101
|
-
|
|
1102
|
-
if (!valid) {
|
|
1103
|
-
res.sendStatus(401);
|
|
1122
|
+
this.api.post(
|
|
1123
|
+
"/auth",
|
|
1124
|
+
authLimiter,
|
|
1125
|
+
accountAuthLimiter,
|
|
1126
|
+
async (req, res) => {
|
|
1127
|
+
const parsed = authPayload.safeParse(req.body);
|
|
1128
|
+
if (!parsed.success) {
|
|
1129
|
+
res.status(400).json({
|
|
1130
|
+
error: "Invalid credentials format",
|
|
1131
|
+
});
|
|
1104
1132
|
return;
|
|
1105
1133
|
}
|
|
1134
|
+
const { password } = parsed.data;
|
|
1135
|
+
const username = parsed.data.username.toLowerCase();
|
|
1106
1136
|
|
|
1107
|
-
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1137
|
+
try {
|
|
1138
|
+
const userEntry = await this.db.retrieveUser(username);
|
|
1139
|
+
if (!userEntry) {
|
|
1140
|
+
await verifyPassword(password, null);
|
|
1141
|
+
res.sendStatus(401);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1111
1144
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
"Passkey verification does not match this account.",
|
|
1117
|
-
);
|
|
1118
|
-
if (passkeyError) {
|
|
1119
|
-
res.status(403).send({
|
|
1120
|
-
error: passkeyError,
|
|
1121
|
-
});
|
|
1122
|
-
return;
|
|
1123
|
-
}
|
|
1145
|
+
const { needsRehash, valid } = await verifyPassword(
|
|
1146
|
+
password,
|
|
1147
|
+
userEntry,
|
|
1148
|
+
);
|
|
1124
1149
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
);
|
|
1150
|
+
if (!valid) {
|
|
1151
|
+
res.sendStatus(401);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1130
1154
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1155
|
+
if (needsRehash) {
|
|
1156
|
+
const newHash = await hashPasswordArgon2(password);
|
|
1157
|
+
await this.db.rehashPassword(userEntry.userID, newHash);
|
|
1158
|
+
}
|
|
1133
1159
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
// debugger: auth error
|
|
1139
|
-
res.sendStatus(500);
|
|
1140
|
-
}
|
|
1141
|
-
});
|
|
1160
|
+
const token = signAuthJwt(
|
|
1161
|
+
{ scope: "user", user: censorUser(userEntry) },
|
|
1162
|
+
JWT_EXPIRY,
|
|
1163
|
+
);
|
|
1142
1164
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
issues: regParsed.error.issues,
|
|
1150
|
-
});
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
const regPayload = regParsed.data;
|
|
1154
|
-
if (
|
|
1155
|
-
regPayload.username !== undefined &&
|
|
1156
|
-
!usernameRegex.test(regPayload.username)
|
|
1157
|
-
) {
|
|
1158
|
-
res.status(400).send({
|
|
1159
|
-
error: "Username must be between three and nineteen letters, digits, or underscores.",
|
|
1160
|
-
});
|
|
1161
|
-
return;
|
|
1165
|
+
res.send(
|
|
1166
|
+
msgpack.encode({ token, user: censorUser(userEntry) }),
|
|
1167
|
+
);
|
|
1168
|
+
} catch (_err: unknown) {
|
|
1169
|
+
// debugger: auth error
|
|
1170
|
+
res.sendStatus(500);
|
|
1162
1171
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
username: normalizeRegistrationUsername(
|
|
1166
|
-
regPayload.username,
|
|
1167
|
-
regPayload.signKey,
|
|
1168
|
-
),
|
|
1169
|
-
};
|
|
1170
|
-
|
|
1171
|
-
const regKey = await spireXSignOpenAsync(
|
|
1172
|
-
XUtils.decodeHex(normalizedPayload.signed),
|
|
1173
|
-
XUtils.decodeHex(normalizedPayload.signKey),
|
|
1174
|
-
);
|
|
1172
|
+
},
|
|
1173
|
+
);
|
|
1175
1174
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
const existingUser = await this.db.retrieveUser(
|
|
1185
|
-
normalizedPayload.username,
|
|
1175
|
+
this.api.post(
|
|
1176
|
+
"/register",
|
|
1177
|
+
authLimiter,
|
|
1178
|
+
accountAuthLimiter,
|
|
1179
|
+
async (req, res) => {
|
|
1180
|
+
try {
|
|
1181
|
+
const regParsed = RegistrationPayloadSchema.safeParse(
|
|
1182
|
+
req.body,
|
|
1186
1183
|
);
|
|
1187
|
-
if (
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
res.status(400).send({
|
|
1193
|
-
error: "Public key is already registered.",
|
|
1194
|
-
});
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
let requesterPasskeyID: string | undefined;
|
|
1198
|
-
if (req.passkey?.passkeyID) {
|
|
1199
|
-
const passkeyError = await passkeySecondFactorError(
|
|
1200
|
-
this.db,
|
|
1201
|
-
existingUser.userID,
|
|
1202
|
-
req.passkey.passkeyID,
|
|
1203
|
-
"Passkey verification does not match this account.",
|
|
1204
|
-
);
|
|
1205
|
-
if (passkeyError) {
|
|
1206
|
-
res.status(403).send({
|
|
1207
|
-
error: passkeyError,
|
|
1208
|
-
});
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
requesterPasskeyID = req.passkey.passkeyID;
|
|
1212
|
-
}
|
|
1213
|
-
const pendingResponse =
|
|
1214
|
-
createPendingDeviceEnrollmentRequest(
|
|
1215
|
-
existingUser.userID,
|
|
1216
|
-
normalizedPayload,
|
|
1217
|
-
this.notify.bind(this),
|
|
1218
|
-
{
|
|
1219
|
-
deferOwnerNotification: true,
|
|
1220
|
-
...(requesterPasskeyID
|
|
1221
|
-
? { requesterPasskeyID }
|
|
1222
|
-
: {}),
|
|
1223
|
-
},
|
|
1224
|
-
);
|
|
1225
|
-
res.status(202).send(msgpack.encode(pendingResponse));
|
|
1184
|
+
if (!regParsed.success) {
|
|
1185
|
+
res.status(400).json({
|
|
1186
|
+
error: "Invalid registration payload",
|
|
1187
|
+
issues: regParsed.error.issues,
|
|
1188
|
+
});
|
|
1226
1189
|
return;
|
|
1227
1190
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
) {
|
|
1191
|
+
const regPayload = regParsed.data;
|
|
1192
|
+
const bounded =
|
|
1193
|
+
boundedRegistrationPayload.safeParse(regPayload);
|
|
1194
|
+
if (!bounded.success) {
|
|
1195
|
+
res.status(400).json({
|
|
1196
|
+
error: "Invalid registration payload",
|
|
1197
|
+
issues: bounded.error.issues,
|
|
1198
|
+
});
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
if (!usernameRegex.test(regPayload.username)) {
|
|
1232
1202
|
res.status(400).send({
|
|
1233
|
-
error: "
|
|
1203
|
+
error: "Username must be between three and nineteen letters, digits, or underscores.",
|
|
1234
1204
|
});
|
|
1235
1205
|
return;
|
|
1236
1206
|
}
|
|
1237
|
-
const
|
|
1238
|
-
|
|
1239
|
-
|
|
1207
|
+
const normalizedPayload = {
|
|
1208
|
+
...regPayload,
|
|
1209
|
+
username: normalizeRegistrationUsername(
|
|
1210
|
+
regPayload.username,
|
|
1211
|
+
),
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
const regKey = await spireXSignOpenAsync(
|
|
1215
|
+
XUtils.decodeHex(normalizedPayload.signed),
|
|
1216
|
+
XUtils.decodeHex(normalizedPayload.signKey),
|
|
1240
1217
|
);
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
errCode === "SQLITE_CONSTRAINT_UNIQUE" ||
|
|
1256
|
-
errText.includes("UNIQUE constraint failed");
|
|
1257
|
-
if (isUniqueConstraint && usernameConflict) {
|
|
1218
|
+
|
|
1219
|
+
if (
|
|
1220
|
+
regKey &&
|
|
1221
|
+
regKey.length === 16 &&
|
|
1222
|
+
this.validateToken(
|
|
1223
|
+
uuidStringify(regKey),
|
|
1224
|
+
TokenScopes.Register,
|
|
1225
|
+
)
|
|
1226
|
+
) {
|
|
1227
|
+
if (
|
|
1228
|
+
!(await verifyDevicePayloadPreKeySignature(
|
|
1229
|
+
normalizedPayload,
|
|
1230
|
+
))
|
|
1231
|
+
) {
|
|
1258
1232
|
res.status(400).send({
|
|
1259
|
-
error: "
|
|
1233
|
+
error: "Signed prekey signature is invalid.",
|
|
1260
1234
|
});
|
|
1261
1235
|
return;
|
|
1262
1236
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1237
|
+
const existingUser = await this.db.retrieveUser(
|
|
1238
|
+
normalizedPayload.username,
|
|
1239
|
+
);
|
|
1240
|
+
if (
|
|
1241
|
+
normalizedPayload.intent === "create-account" &&
|
|
1242
|
+
existingUser
|
|
1243
|
+
) {
|
|
1244
|
+
res.status(409).send({
|
|
1245
|
+
error: "Username is already registered. Sign in instead.",
|
|
1266
1246
|
});
|
|
1267
1247
|
return;
|
|
1268
1248
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1249
|
+
if (
|
|
1250
|
+
normalizedPayload.intent === "enroll-device" &&
|
|
1251
|
+
!existingUser
|
|
1252
|
+
) {
|
|
1253
|
+
await verifyPassword(
|
|
1254
|
+
normalizedPayload.password ?? "",
|
|
1255
|
+
null,
|
|
1256
|
+
);
|
|
1257
|
+
res.status(401).send({
|
|
1258
|
+
error: "Invalid username or password.",
|
|
1259
|
+
});
|
|
1273
1260
|
return;
|
|
1274
1261
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1262
|
+
if (existingUser) {
|
|
1263
|
+
const existingBySignKey =
|
|
1264
|
+
await this.db.retrieveDevice(
|
|
1265
|
+
normalizedPayload.signKey,
|
|
1266
|
+
);
|
|
1267
|
+
if (existingBySignKey) {
|
|
1268
|
+
res.status(400).send({
|
|
1269
|
+
error: "Public key is already registered.",
|
|
1270
|
+
});
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
let requesterPasskeyID: string | undefined;
|
|
1274
|
+
if (req.passkey?.passkeyID) {
|
|
1275
|
+
const passkey =
|
|
1276
|
+
await this.db.retrievePasskeyInternal(
|
|
1277
|
+
req.passkey.passkeyID,
|
|
1278
|
+
);
|
|
1279
|
+
if (
|
|
1280
|
+
!passkey ||
|
|
1281
|
+
passkey.userID !== existingUser.userID
|
|
1282
|
+
) {
|
|
1283
|
+
res.status(403).send({
|
|
1284
|
+
error: "Passkey verification does not match this account.",
|
|
1285
|
+
});
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
requesterPasskeyID = req.passkey.passkeyID;
|
|
1289
|
+
} else {
|
|
1290
|
+
if (
|
|
1291
|
+
typeof normalizedPayload.password !==
|
|
1292
|
+
"string" ||
|
|
1293
|
+
normalizedPayload.password.trim().length ===
|
|
1294
|
+
0
|
|
1295
|
+
) {
|
|
1296
|
+
res.status(401).send({
|
|
1297
|
+
error: "Password is required to add this device.",
|
|
1298
|
+
});
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
const { needsRehash, valid } =
|
|
1302
|
+
await verifyPassword(
|
|
1303
|
+
normalizedPayload.password,
|
|
1304
|
+
existingUser,
|
|
1305
|
+
);
|
|
1306
|
+
if (!valid) {
|
|
1307
|
+
res.sendStatus(401);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
if (needsRehash) {
|
|
1311
|
+
const newHash = await hashPasswordArgon2(
|
|
1312
|
+
normalizedPayload.password,
|
|
1313
|
+
);
|
|
1314
|
+
await this.db.rehashPassword(
|
|
1315
|
+
existingUser.userID,
|
|
1316
|
+
newHash,
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
const activeDevices =
|
|
1321
|
+
await this.db.retrieveUserDeviceList([
|
|
1322
|
+
existingUser.userID,
|
|
1323
|
+
]);
|
|
1324
|
+
if (
|
|
1325
|
+
activeDevices.length >=
|
|
1326
|
+
MAX_ACTIVE_DEVICES_PER_USER
|
|
1327
|
+
) {
|
|
1328
|
+
res.status(409).send({
|
|
1329
|
+
error: `Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices. Remove an old device before adding another.`,
|
|
1330
|
+
});
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const pendingResponse =
|
|
1334
|
+
createPendingDeviceEnrollmentRequest(
|
|
1335
|
+
existingUser.userID,
|
|
1336
|
+
normalizedPayload,
|
|
1337
|
+
this.notify.bind(this),
|
|
1338
|
+
{
|
|
1339
|
+
deferOwnerNotification: true,
|
|
1340
|
+
...(requesterPasskeyID
|
|
1341
|
+
? { requesterPasskeyID }
|
|
1342
|
+
: {}),
|
|
1343
|
+
},
|
|
1344
|
+
);
|
|
1345
|
+
res.status(202).send(
|
|
1346
|
+
msgpack.encode(pendingResponse),
|
|
1347
|
+
);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (
|
|
1351
|
+
typeof normalizedPayload.password !== "string" ||
|
|
1352
|
+
normalizedPayload.password.trim().length === 0
|
|
1353
|
+
) {
|
|
1354
|
+
res.status(400).send({
|
|
1355
|
+
error: "Password is required to register a new account.",
|
|
1356
|
+
});
|
|
1280
1357
|
return;
|
|
1281
1358
|
}
|
|
1282
|
-
const
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
getJwtSecret(),
|
|
1286
|
-
{ expiresIn: JWT_EXPIRY },
|
|
1359
|
+
const passwordError = validateAccountPassword(
|
|
1360
|
+
normalizedPayload.password,
|
|
1361
|
+
normalizedPayload.username,
|
|
1287
1362
|
);
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1363
|
+
if (passwordError) {
|
|
1364
|
+
res.status(400).send({
|
|
1365
|
+
error: passwordError,
|
|
1366
|
+
});
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const [user, err] = await this.db.createUser(
|
|
1370
|
+
regKey,
|
|
1371
|
+
normalizedPayload,
|
|
1295
1372
|
);
|
|
1373
|
+
if (err !== null) {
|
|
1374
|
+
const errCode =
|
|
1375
|
+
"code" in err && typeof err.code === "string"
|
|
1376
|
+
? err.code
|
|
1377
|
+
: undefined;
|
|
1378
|
+
const errText = String(err);
|
|
1379
|
+
const usernameConflict =
|
|
1380
|
+
errText.includes("users_username_unique") ||
|
|
1381
|
+
errText.includes("users.username");
|
|
1382
|
+
const signKeyConflict =
|
|
1383
|
+
errText.includes("devices_signKey_unique") ||
|
|
1384
|
+
errText.includes("devices.signKey");
|
|
1385
|
+
const isUniqueConstraint =
|
|
1386
|
+
errCode === "ER_DUP_ENTRY" ||
|
|
1387
|
+
errCode === "SQLITE_CONSTRAINT_UNIQUE" ||
|
|
1388
|
+
errText.includes("UNIQUE constraint failed");
|
|
1389
|
+
if (isUniqueConstraint && usernameConflict) {
|
|
1390
|
+
res.status(400).send({
|
|
1391
|
+
error: "Username is already registered.",
|
|
1392
|
+
});
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (isUniqueConstraint && signKeyConflict) {
|
|
1396
|
+
res.status(400).send({
|
|
1397
|
+
error: "Public key is already registered.",
|
|
1398
|
+
});
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
res.sendStatus(500);
|
|
1402
|
+
} else {
|
|
1403
|
+
if (!user) {
|
|
1404
|
+
res.sendStatus(500);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
const device = await this.db.retrieveDevice(
|
|
1408
|
+
normalizedPayload.signKey,
|
|
1409
|
+
);
|
|
1410
|
+
if (!device) {
|
|
1411
|
+
res.sendStatus(500);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
const censored = censorUser(user);
|
|
1415
|
+
const token = signAuthJwt(
|
|
1416
|
+
{ scope: "user", user: censored },
|
|
1417
|
+
JWT_EXPIRY,
|
|
1418
|
+
);
|
|
1419
|
+
res.send(
|
|
1420
|
+
msgpack.encode({
|
|
1421
|
+
device,
|
|
1422
|
+
token,
|
|
1423
|
+
user: censored,
|
|
1424
|
+
}),
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
} else if (regKey && regKey.length !== 16) {
|
|
1428
|
+
res.status(400).send({
|
|
1429
|
+
error: "Invalid registration token payload.",
|
|
1430
|
+
});
|
|
1431
|
+
} else {
|
|
1432
|
+
res.status(400).send({
|
|
1433
|
+
error: "Invalid or no token supplied.",
|
|
1434
|
+
});
|
|
1296
1435
|
}
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1436
|
+
} catch (err: unknown) {
|
|
1437
|
+
const requestId = crypto.randomUUID();
|
|
1438
|
+
const message =
|
|
1439
|
+
err instanceof Error ? err.message : String(err);
|
|
1440
|
+
console.error(
|
|
1441
|
+
`[spire] /register failed requestId=${requestId} profile=${this.cryptoProfile} message=${message}`,
|
|
1442
|
+
);
|
|
1443
|
+
if (err instanceof Error && err.stack) {
|
|
1444
|
+
console.error(err.stack);
|
|
1445
|
+
}
|
|
1446
|
+
res.status(500).json({
|
|
1447
|
+
error: `Registration failed. requestId=${requestId}`,
|
|
1304
1448
|
});
|
|
1305
1449
|
}
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
const message =
|
|
1309
|
-
err instanceof Error ? err.message : String(err);
|
|
1310
|
-
console.error(
|
|
1311
|
-
`[spire] /register failed requestId=${requestId} profile=${this.cryptoProfile} message=${message}`,
|
|
1312
|
-
);
|
|
1313
|
-
if (err instanceof Error && err.stack) {
|
|
1314
|
-
console.error(err.stack);
|
|
1315
|
-
}
|
|
1316
|
-
res.status(500).json({
|
|
1317
|
-
error: `Registration failed. requestId=${requestId}`,
|
|
1318
|
-
});
|
|
1319
|
-
}
|
|
1320
|
-
});
|
|
1450
|
+
},
|
|
1451
|
+
);
|
|
1321
1452
|
|
|
1322
1453
|
this.server = this.api.listen(apiPort);
|
|
1323
1454
|
|
|
@@ -1349,6 +1480,18 @@ export class Spire extends EventEmitter {
|
|
|
1349
1480
|
});
|
|
1350
1481
|
}
|
|
1351
1482
|
|
|
1483
|
+
private pruneActionTokens(now = Date.now()): void {
|
|
1484
|
+
for (const [key, token] of this.actionTokens) {
|
|
1485
|
+
const createdAt = new Date(token.time).getTime();
|
|
1486
|
+
if (
|
|
1487
|
+
!Number.isFinite(createdAt) ||
|
|
1488
|
+
now - createdAt >= TOKEN_EXPIRY
|
|
1489
|
+
) {
|
|
1490
|
+
this.actionTokens.delete(key);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1352
1495
|
private removeClient(client: ClientManager): void {
|
|
1353
1496
|
const idx = this.clients.indexOf(client);
|
|
1354
1497
|
if (idx !== -1) {
|
|
@@ -1357,20 +1500,13 @@ export class Spire extends EventEmitter {
|
|
|
1357
1500
|
}
|
|
1358
1501
|
|
|
1359
1502
|
private validateToken(key: string, scope: TokenScopes): boolean {
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
const age = Date.now() - new Date(rKey.time).getTime();
|
|
1367
|
-
if (age < TOKEN_EXPIRY) {
|
|
1368
|
-
this.deleteActionToken(rKey);
|
|
1369
|
-
return true;
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1503
|
+
this.pruneActionTokens();
|
|
1504
|
+
const token = this.actionTokens.get(key);
|
|
1505
|
+
if (!token || token.scope !== scope) {
|
|
1506
|
+
return false;
|
|
1372
1507
|
}
|
|
1373
|
-
|
|
1508
|
+
this.deleteActionToken(token);
|
|
1509
|
+
return true;
|
|
1374
1510
|
}
|
|
1375
1511
|
}
|
|
1376
1512
|
|
|
@@ -1378,19 +1514,7 @@ export class Spire extends EventEmitter {
|
|
|
1378
1514
|
// `user` must resolve to the same account. We canonicalize to
|
|
1379
1515
|
// lowercase at the registration boundary so the persisted row, the
|
|
1380
1516
|
// UNIQUE index, and every downstream lookup all agree on a single
|
|
1381
|
-
// representation.
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
username: string | undefined,
|
|
1385
|
-
signKeyHex: string,
|
|
1386
|
-
): string {
|
|
1387
|
-
const trimmed = username?.trim().toLowerCase();
|
|
1388
|
-
if (trimmed && trimmed.length > 0) {
|
|
1389
|
-
return trimmed;
|
|
1390
|
-
}
|
|
1391
|
-
const seed = signKeyHex
|
|
1392
|
-
.toLowerCase()
|
|
1393
|
-
.replace(/[^a-f0-9]/g, "")
|
|
1394
|
-
.slice(0, 12);
|
|
1395
|
-
return `key_${seed}`;
|
|
1517
|
+
// representation. `Database.retrieveUser` applies the same normalization.
|
|
1518
|
+
function normalizeRegistrationUsername(username: string): string {
|
|
1519
|
+
return username.trim().toLowerCase();
|
|
1396
1520
|
}
|