@vex-chat/spire 3.0.1 → 4.1.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 +20 -11
- package/dist/Database.js +229 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -2
- package/dist/Spire.js +155 -72
- 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 +33 -6
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +109 -11
- package/dist/server/cliPasskeyPage.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 +319 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +367 -29
- 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/password.d.ts +7 -0
- 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/serverIcon.d.ts +10 -0
- package/dist/server/serverIcon.js +158 -0
- package/dist/server/serverIcon.js.map +1 -0
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +40 -9
- 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 +294 -152
- package/src/Spire.ts +412 -285
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
- 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 +7 -2
- 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/__tests__/serverManagement.spec.ts +262 -0
- 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 +32 -6
- package/src/server/cliPasskeyPage.ts +109 -11
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +494 -139
- package/src/server/passkey.ts +531 -31
- 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/serverIcon.ts +199 -0
- package/src/server/user.ts +44 -9
- 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/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
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,18 +59,23 @@ import {
|
|
|
53
59
|
MailIngressValidationError,
|
|
54
60
|
validateMailIngress,
|
|
55
61
|
} from "./server/mailIngress.ts";
|
|
56
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
accountAuthLimiter,
|
|
64
|
+
authLimiter,
|
|
65
|
+
devApiKeySkipsRateLimits,
|
|
66
|
+
} from "./server/rateLimit.ts";
|
|
57
67
|
import { createPendingDeviceEnrollmentRequest } from "./server/user.ts";
|
|
58
68
|
import { censorUser, getParam, getUser } from "./server/utils.ts";
|
|
59
69
|
import { resolveSpireListenPort } from "./spireListenPort.ts";
|
|
60
|
-
import {
|
|
70
|
+
import { signAuthJwt, verifyAuthJwt } from "./utils/authJwt.ts";
|
|
61
71
|
import { msgpack } from "./utils/msgpack.ts";
|
|
72
|
+
import { verifyDevicePayloadPreKeySignature } from "./utils/preKeySignature.ts";
|
|
62
73
|
import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
|
|
63
74
|
|
|
64
|
-
//
|
|
75
|
+
// One-use action tokens expire after ten minutes.
|
|
65
76
|
export const TOKEN_EXPIRY = 1000 * 60 * 10;
|
|
66
|
-
export const JWT_EXPIRY = "
|
|
67
|
-
export const DEVICE_AUTH_JWT_EXPIRY = "
|
|
77
|
+
export const JWT_EXPIRY = "1h";
|
|
78
|
+
export const DEVICE_AUTH_JWT_EXPIRY = "1h";
|
|
68
79
|
/**
|
|
69
80
|
* Passkey-scoped JWTs grant destructive admin powers (delete a
|
|
70
81
|
* device, recover a device enrollment) without further user
|
|
@@ -76,6 +87,8 @@ const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
|
|
|
76
87
|
|
|
77
88
|
// 3-19 chars long
|
|
78
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;
|
|
79
92
|
|
|
80
93
|
// ── Zod schemas for trust-boundary validation ──────────────────────────
|
|
81
94
|
const wsAuthMsg = z.object({
|
|
@@ -86,26 +99,65 @@ const wsAuthMsg = z.object({
|
|
|
86
99
|
const jwtPayload = z.object({
|
|
87
100
|
bearerToken: z.string().optional(),
|
|
88
101
|
exp: z.number().optional(),
|
|
102
|
+
scope: z.literal("user"),
|
|
89
103
|
user: UserSchema,
|
|
90
104
|
});
|
|
91
105
|
|
|
92
106
|
const authPayload = z.object({
|
|
93
|
-
password: z.string().min(1),
|
|
94
|
-
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(),
|
|
95
137
|
});
|
|
96
138
|
|
|
97
139
|
const deviceAuthPayload = z.object({
|
|
98
|
-
deviceID: z.string().
|
|
99
|
-
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})+$/),
|
|
100
146
|
});
|
|
101
147
|
|
|
102
148
|
const deviceVerifyPayload = z.object({
|
|
103
|
-
challengeID: z.string().
|
|
104
|
-
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})+$/),
|
|
105
155
|
});
|
|
106
156
|
|
|
107
157
|
const mailPostPayload = z.object({
|
|
108
|
-
header: z.custom<Uint8Array>(
|
|
158
|
+
header: z.custom<Uint8Array>(
|
|
159
|
+
(val) => val instanceof Uint8Array && val.byteLength === 32,
|
|
160
|
+
),
|
|
109
161
|
mail: MailWSSchema,
|
|
110
162
|
});
|
|
111
163
|
const MAIL_BATCH_MAX_ITEMS = 256;
|
|
@@ -131,12 +183,12 @@ interface ValidatedMailBatchEntry {
|
|
|
131
183
|
|
|
132
184
|
const notificationSubscribePayload = z.object({
|
|
133
185
|
channel: z.literal("expo"),
|
|
134
|
-
events: z.array(z.string().min(1)).default(["mail"]),
|
|
186
|
+
events: z.array(z.string().min(1).max(64)).max(32).default(["mail"]),
|
|
135
187
|
platform: z.enum(["android", "ios", "web"]).optional(),
|
|
136
|
-
token: z.string().min(1),
|
|
188
|
+
token: z.string().min(1).max(4096),
|
|
137
189
|
});
|
|
138
190
|
|
|
139
|
-
const directories = ["files", "avatars", "emoji"];
|
|
191
|
+
const directories = ["files", "avatars", "emoji", "server-icons"];
|
|
140
192
|
for (const dir of directories) {
|
|
141
193
|
if (!fs.existsSync(dir)) {
|
|
142
194
|
fs.mkdirSync(dir);
|
|
@@ -194,6 +246,19 @@ export interface SpireOptions {
|
|
|
194
246
|
dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
|
|
195
247
|
}
|
|
196
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
|
+
|
|
197
262
|
/**
|
|
198
263
|
* Masks identifying material in the access-log URL: hyphenated UUIDs, and
|
|
199
264
|
* `/device/...` path segments that hold public-key material (32B ed25519 hex, or
|
|
@@ -213,7 +278,7 @@ function redactAccessLogUrl(url: string): string {
|
|
|
213
278
|
let pendingFipsKeyPair: KeyPair | null = null;
|
|
214
279
|
|
|
215
280
|
export class Spire extends EventEmitter {
|
|
216
|
-
private actionTokens
|
|
281
|
+
private actionTokens = new Map<string, ActionToken>();
|
|
217
282
|
private api = express();
|
|
218
283
|
private calls: CallManager;
|
|
219
284
|
private clients: ClientManager[] = [];
|
|
@@ -258,12 +323,13 @@ export class Spire extends EventEmitter {
|
|
|
258
323
|
this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
|
|
259
324
|
}
|
|
260
325
|
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
);
|
|
267
333
|
this.api.disable("etag");
|
|
268
334
|
|
|
269
335
|
this.db = new Database(options);
|
|
@@ -359,20 +425,18 @@ export class Spire extends EventEmitter {
|
|
|
359
425
|
}
|
|
360
426
|
|
|
361
427
|
private createActionToken(scope: TokenScopes): ActionToken {
|
|
428
|
+
this.pruneActionTokens();
|
|
362
429
|
const token: ActionToken = {
|
|
363
430
|
key: crypto.randomUUID(),
|
|
364
431
|
scope,
|
|
365
432
|
time: new Date().toISOString(),
|
|
366
433
|
};
|
|
367
|
-
this.actionTokens.
|
|
434
|
+
this.actionTokens.set(token.key, token);
|
|
368
435
|
return token;
|
|
369
436
|
}
|
|
370
437
|
|
|
371
438
|
private deleteActionToken(key: ActionToken) {
|
|
372
|
-
|
|
373
|
-
if (idx !== -1) {
|
|
374
|
-
this.actionTokens.splice(idx, 1);
|
|
375
|
-
}
|
|
439
|
+
this.actionTokens.delete(key.key);
|
|
376
440
|
}
|
|
377
441
|
|
|
378
442
|
private disconnectDevices(deviceIDs: string[]): void {
|
|
@@ -454,10 +518,7 @@ export class Spire extends EventEmitter {
|
|
|
454
518
|
JSON.stringify(authResult.error.issues),
|
|
455
519
|
);
|
|
456
520
|
}
|
|
457
|
-
const result =
|
|
458
|
-
authResult.data.token,
|
|
459
|
-
getJwtSecret(),
|
|
460
|
-
);
|
|
521
|
+
const result = verifyAuthJwt(authResult.data.token);
|
|
461
522
|
const jwtResult = jwtPayload.safeParse(result);
|
|
462
523
|
if (!jwtResult.success) {
|
|
463
524
|
throw new Error(
|
|
@@ -689,9 +750,10 @@ export class Spire extends EventEmitter {
|
|
|
689
750
|
}
|
|
690
751
|
});
|
|
691
752
|
|
|
692
|
-
this.api.post("/goodbye", protect, (
|
|
693
|
-
|
|
694
|
-
|
|
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);
|
|
695
757
|
});
|
|
696
758
|
|
|
697
759
|
// ── Device-key auth ──────────────────────────────────────────
|
|
@@ -794,11 +856,10 @@ export class Spire extends EventEmitter {
|
|
|
794
856
|
.send({ error: "Device owner not found." });
|
|
795
857
|
}
|
|
796
858
|
|
|
797
|
-
//
|
|
798
|
-
const token =
|
|
799
|
-
{ user: censorUser(user) },
|
|
800
|
-
|
|
801
|
-
{ 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,
|
|
802
863
|
);
|
|
803
864
|
return res.send(
|
|
804
865
|
msgpack.encode({ token, user: censorUser(user) }),
|
|
@@ -1058,263 +1119,336 @@ export class Spire extends EventEmitter {
|
|
|
1058
1119
|
},
|
|
1059
1120
|
);
|
|
1060
1121
|
|
|
1061
|
-
this.api.post(
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
try {
|
|
1072
|
-
const userEntry = await this.db.retrieveUser(username);
|
|
1073
|
-
if (!userEntry) {
|
|
1074
|
-
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
|
+
});
|
|
1075
1132
|
return;
|
|
1076
1133
|
}
|
|
1134
|
+
const { password } = parsed.data;
|
|
1135
|
+
const username = parsed.data.username.toLowerCase();
|
|
1077
1136
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
userEntry
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
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
|
+
}
|
|
1087
1144
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1145
|
+
const { needsRehash, valid } = await verifyPassword(
|
|
1146
|
+
password,
|
|
1147
|
+
userEntry,
|
|
1148
|
+
);
|
|
1092
1149
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
);
|
|
1150
|
+
if (!valid) {
|
|
1151
|
+
res.sendStatus(401);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1098
1154
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1155
|
+
if (needsRehash) {
|
|
1156
|
+
const newHash = await hashPasswordArgon2(password);
|
|
1157
|
+
await this.db.rehashPassword(userEntry.userID, newHash);
|
|
1158
|
+
}
|
|
1101
1159
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
// debugger: auth error
|
|
1107
|
-
res.sendStatus(500);
|
|
1108
|
-
}
|
|
1109
|
-
});
|
|
1160
|
+
const token = signAuthJwt(
|
|
1161
|
+
{ scope: "user", user: censorUser(userEntry) },
|
|
1162
|
+
JWT_EXPIRY,
|
|
1163
|
+
);
|
|
1110
1164
|
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
issues: regParsed.error.issues,
|
|
1118
|
-
});
|
|
1119
|
-
return;
|
|
1120
|
-
}
|
|
1121
|
-
const regPayload = regParsed.data;
|
|
1122
|
-
if (
|
|
1123
|
-
regPayload.username !== undefined &&
|
|
1124
|
-
!usernameRegex.test(regPayload.username)
|
|
1125
|
-
) {
|
|
1126
|
-
res.status(400).send({
|
|
1127
|
-
error: "Username must be between three and nineteen letters, digits, or underscores.",
|
|
1128
|
-
});
|
|
1129
|
-
return;
|
|
1165
|
+
res.send(
|
|
1166
|
+
msgpack.encode({ token, user: censorUser(userEntry) }),
|
|
1167
|
+
);
|
|
1168
|
+
} catch (_err: unknown) {
|
|
1169
|
+
// debugger: auth error
|
|
1170
|
+
res.sendStatus(500);
|
|
1130
1171
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1172
|
+
},
|
|
1173
|
+
);
|
|
1174
|
+
|
|
1175
|
+
this.api.post(
|
|
1176
|
+
"/register",
|
|
1177
|
+
authLimiter,
|
|
1178
|
+
accountAuthLimiter,
|
|
1179
|
+
async (req, res) => {
|
|
1180
|
+
try {
|
|
1181
|
+
const regParsed = RegistrationPayloadSchema.safeParse(
|
|
1182
|
+
req.body,
|
|
1183
|
+
);
|
|
1184
|
+
if (!regParsed.success) {
|
|
1185
|
+
res.status(400).json({
|
|
1186
|
+
error: "Invalid registration payload",
|
|
1187
|
+
issues: regParsed.error.issues,
|
|
1188
|
+
});
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
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)) {
|
|
1202
|
+
res.status(400).send({
|
|
1203
|
+
error: "Username must be between three and nineteen letters, digits, or underscores.",
|
|
1204
|
+
});
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
const normalizedPayload = {
|
|
1208
|
+
...regPayload,
|
|
1209
|
+
username: normalizeRegistrationUsername(
|
|
1210
|
+
regPayload.username,
|
|
1211
|
+
),
|
|
1212
|
+
};
|
|
1143
1213
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
this.validateToken(
|
|
1148
|
-
uuidStringify(regKey),
|
|
1149
|
-
TokenScopes.Register,
|
|
1150
|
-
)
|
|
1151
|
-
) {
|
|
1152
|
-
const existingUser = await this.db.retrieveUser(
|
|
1153
|
-
normalizedPayload.username,
|
|
1214
|
+
const regKey = await spireXSignOpenAsync(
|
|
1215
|
+
XUtils.decodeHex(normalizedPayload.signed),
|
|
1216
|
+
XUtils.decodeHex(normalizedPayload.signKey),
|
|
1154
1217
|
);
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
+
) {
|
|
1160
1232
|
res.status(400).send({
|
|
1161
|
-
error: "
|
|
1233
|
+
error: "Signed prekey signature is invalid.",
|
|
1234
|
+
});
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
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.",
|
|
1162
1246
|
});
|
|
1163
1247
|
return;
|
|
1164
1248
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
+
});
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (existingUser) {
|
|
1263
|
+
const existingBySignKey =
|
|
1264
|
+
await this.db.retrieveDevice(
|
|
1265
|
+
normalizedPayload.signKey,
|
|
1170
1266
|
);
|
|
1171
|
-
if (
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
) {
|
|
1175
|
-
res.status(403).send({
|
|
1176
|
-
error: "Passkey verification does not match this account.",
|
|
1267
|
+
if (existingBySignKey) {
|
|
1268
|
+
res.status(400).send({
|
|
1269
|
+
error: "Public key is already registered.",
|
|
1177
1270
|
});
|
|
1178
1271
|
return;
|
|
1179
1272
|
}
|
|
1180
|
-
requesterPasskeyID
|
|
1181
|
-
|
|
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
|
+
]);
|
|
1182
1324
|
if (
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
normalizedPayload.password.trim().length === 0
|
|
1325
|
+
activeDevices.length >=
|
|
1326
|
+
MAX_ACTIVE_DEVICES_PER_USER
|
|
1186
1327
|
) {
|
|
1187
|
-
res.status(
|
|
1188
|
-
error:
|
|
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.`,
|
|
1189
1330
|
});
|
|
1190
1331
|
return;
|
|
1191
1332
|
}
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
existingUser,
|
|
1195
|
-
);
|
|
1196
|
-
if (!valid) {
|
|
1197
|
-
res.sendStatus(401);
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
if (needsRehash) {
|
|
1201
|
-
const newHash = await hashPasswordArgon2(
|
|
1202
|
-
normalizedPayload.password,
|
|
1203
|
-
);
|
|
1204
|
-
await this.db.rehashPassword(
|
|
1333
|
+
const pendingResponse =
|
|
1334
|
+
createPendingDeviceEnrollmentRequest(
|
|
1205
1335
|
existingUser.userID,
|
|
1206
|
-
|
|
1336
|
+
normalizedPayload,
|
|
1337
|
+
this.notify.bind(this),
|
|
1338
|
+
{
|
|
1339
|
+
deferOwnerNotification: true,
|
|
1340
|
+
...(requesterPasskeyID
|
|
1341
|
+
? { requesterPasskeyID }
|
|
1342
|
+
: {}),
|
|
1343
|
+
},
|
|
1207
1344
|
);
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
const pendingResponse =
|
|
1211
|
-
createPendingDeviceEnrollmentRequest(
|
|
1212
|
-
existingUser.userID,
|
|
1213
|
-
normalizedPayload,
|
|
1214
|
-
this.notify.bind(this),
|
|
1215
|
-
{
|
|
1216
|
-
deferOwnerNotification: true,
|
|
1217
|
-
...(requesterPasskeyID
|
|
1218
|
-
? { requesterPasskeyID }
|
|
1219
|
-
: {}),
|
|
1220
|
-
},
|
|
1345
|
+
res.status(202).send(
|
|
1346
|
+
msgpack.encode(pendingResponse),
|
|
1221
1347
|
);
|
|
1222
|
-
res.status(202).send(msgpack.encode(pendingResponse));
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
if (
|
|
1226
|
-
typeof normalizedPayload.password !== "string" ||
|
|
1227
|
-
normalizedPayload.password.trim().length === 0
|
|
1228
|
-
) {
|
|
1229
|
-
res.status(400).send({
|
|
1230
|
-
error: "Password is required to register a new account.",
|
|
1231
|
-
});
|
|
1232
|
-
return;
|
|
1233
|
-
}
|
|
1234
|
-
const [user, err] = await this.db.createUser(
|
|
1235
|
-
regKey,
|
|
1236
|
-
normalizedPayload,
|
|
1237
|
-
);
|
|
1238
|
-
if (err !== null) {
|
|
1239
|
-
const errCode =
|
|
1240
|
-
"code" in err && typeof err.code === "string"
|
|
1241
|
-
? err.code
|
|
1242
|
-
: undefined;
|
|
1243
|
-
const errText = String(err);
|
|
1244
|
-
const usernameConflict =
|
|
1245
|
-
errText.includes("users_username_unique") ||
|
|
1246
|
-
errText.includes("users.username");
|
|
1247
|
-
const signKeyConflict =
|
|
1248
|
-
errText.includes("devices_signKey_unique") ||
|
|
1249
|
-
errText.includes("devices.signKey");
|
|
1250
|
-
const isUniqueConstraint =
|
|
1251
|
-
errCode === "ER_DUP_ENTRY" ||
|
|
1252
|
-
errCode === "SQLITE_CONSTRAINT_UNIQUE" ||
|
|
1253
|
-
errText.includes("UNIQUE constraint failed");
|
|
1254
|
-
if (isUniqueConstraint && usernameConflict) {
|
|
1255
|
-
res.status(400).send({
|
|
1256
|
-
error: "Username is already registered.",
|
|
1257
|
-
});
|
|
1258
1348
|
return;
|
|
1259
1349
|
}
|
|
1260
|
-
if (
|
|
1350
|
+
if (
|
|
1351
|
+
typeof normalizedPayload.password !== "string" ||
|
|
1352
|
+
normalizedPayload.password.trim().length === 0
|
|
1353
|
+
) {
|
|
1261
1354
|
res.status(400).send({
|
|
1262
|
-
error: "
|
|
1355
|
+
error: "Password is required to register a new account.",
|
|
1263
1356
|
});
|
|
1264
1357
|
return;
|
|
1265
1358
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1359
|
+
const passwordError = validateAccountPassword(
|
|
1360
|
+
normalizedPayload.password,
|
|
1361
|
+
normalizedPayload.username,
|
|
1362
|
+
);
|
|
1363
|
+
if (passwordError) {
|
|
1364
|
+
res.status(400).send({
|
|
1365
|
+
error: passwordError,
|
|
1366
|
+
});
|
|
1270
1367
|
return;
|
|
1271
1368
|
}
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1369
|
+
const [user, err] = await this.db.createUser(
|
|
1370
|
+
regKey,
|
|
1371
|
+
normalizedPayload,
|
|
1274
1372
|
);
|
|
1275
|
-
if (
|
|
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
|
+
}
|
|
1276
1401
|
res.sendStatus(500);
|
|
1277
|
-
|
|
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
|
+
);
|
|
1278
1426
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
)
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
msgpack.encode({
|
|
1288
|
-
device,
|
|
1289
|
-
token,
|
|
1290
|
-
user: censored,
|
|
1291
|
-
}),
|
|
1292
|
-
);
|
|
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
|
+
});
|
|
1293
1435
|
}
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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}`,
|
|
1301
1448
|
});
|
|
1302
1449
|
}
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
const message =
|
|
1306
|
-
err instanceof Error ? err.message : String(err);
|
|
1307
|
-
console.error(
|
|
1308
|
-
`[spire] /register failed requestId=${requestId} profile=${this.cryptoProfile} message=${message}`,
|
|
1309
|
-
);
|
|
1310
|
-
if (err instanceof Error && err.stack) {
|
|
1311
|
-
console.error(err.stack);
|
|
1312
|
-
}
|
|
1313
|
-
res.status(500).json({
|
|
1314
|
-
error: `Registration failed. requestId=${requestId}`,
|
|
1315
|
-
});
|
|
1316
|
-
}
|
|
1317
|
-
});
|
|
1450
|
+
},
|
|
1451
|
+
);
|
|
1318
1452
|
|
|
1319
1453
|
this.server = this.api.listen(apiPort);
|
|
1320
1454
|
|
|
@@ -1346,6 +1480,18 @@ export class Spire extends EventEmitter {
|
|
|
1346
1480
|
});
|
|
1347
1481
|
}
|
|
1348
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
|
+
|
|
1349
1495
|
private removeClient(client: ClientManager): void {
|
|
1350
1496
|
const idx = this.clients.indexOf(client);
|
|
1351
1497
|
if (idx !== -1) {
|
|
@@ -1354,20 +1500,13 @@ export class Spire extends EventEmitter {
|
|
|
1354
1500
|
}
|
|
1355
1501
|
|
|
1356
1502
|
private validateToken(key: string, scope: TokenScopes): boolean {
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
const age = Date.now() - new Date(rKey.time).getTime();
|
|
1364
|
-
if (age < TOKEN_EXPIRY) {
|
|
1365
|
-
this.deleteActionToken(rKey);
|
|
1366
|
-
return true;
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1503
|
+
this.pruneActionTokens();
|
|
1504
|
+
const token = this.actionTokens.get(key);
|
|
1505
|
+
if (!token || token.scope !== scope) {
|
|
1506
|
+
return false;
|
|
1369
1507
|
}
|
|
1370
|
-
|
|
1508
|
+
this.deleteActionToken(token);
|
|
1509
|
+
return true;
|
|
1371
1510
|
}
|
|
1372
1511
|
}
|
|
1373
1512
|
|
|
@@ -1375,19 +1514,7 @@ export class Spire extends EventEmitter {
|
|
|
1375
1514
|
// `user` must resolve to the same account. We canonicalize to
|
|
1376
1515
|
// lowercase at the registration boundary so the persisted row, the
|
|
1377
1516
|
// UNIQUE index, and every downstream lookup all agree on a single
|
|
1378
|
-
// representation.
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
username: string | undefined,
|
|
1382
|
-
signKeyHex: string,
|
|
1383
|
-
): string {
|
|
1384
|
-
const trimmed = username?.trim().toLowerCase();
|
|
1385
|
-
if (trimmed && trimmed.length > 0) {
|
|
1386
|
-
return trimmed;
|
|
1387
|
-
}
|
|
1388
|
-
const seed = signKeyHex
|
|
1389
|
-
.toLowerCase()
|
|
1390
|
-
.replace(/[^a-f0-9]/g, "")
|
|
1391
|
-
.slice(0, 12);
|
|
1392
|
-
return `key_${seed}`;
|
|
1517
|
+
// representation. `Database.retrieveUser` applies the same normalization.
|
|
1518
|
+
function normalizeRegistrationUsername(username: string): string {
|
|
1519
|
+
return username.trim().toLowerCase();
|
|
1393
1520
|
}
|