@vex-chat/spire 1.3.6 → 1.4.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/dist/Database.d.ts +36 -1
- package/dist/Database.js +109 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +7 -0
- package/dist/Spire.js +7 -0
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +16 -0
- package/dist/migrations/2026-05-03_passkeys.d.ts +8 -0
- package/dist/migrations/2026-05-03_passkeys.js +44 -0
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +52 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.d.ts +7 -0
- package/dist/server/passkey.js +449 -0
- package/dist/server/passkey.js.map +1 -0
- package/dist/server/passkeyDevices.d.ts +25 -0
- package/dist/server/passkeyDevices.js +118 -0
- package/dist/server/passkeyDevices.js.map +1 -0
- package/dist/server/user.d.ts +27 -1
- package/dist/server/user.js +92 -0
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +10 -0
- package/dist/types/express.js.map +1 -1
- package/package.json +6 -5
- package/src/Database.ts +134 -1
- package/src/Spire.ts +7 -0
- package/src/__tests__/passkeys.spec.ts +217 -0
- package/src/db/schema.ts +18 -1
- package/src/migrations/2026-05-03_passkeys.ts +52 -0
- package/src/server/index.ts +55 -6
- package/src/server/passkey.ts +589 -0
- package/src/server/passkeyDevices.ts +154 -0
- package/src/server/user.ts +114 -1
- package/src/types/express.ts +8 -0
package/src/db/schema.ts
CHANGED
|
@@ -92,10 +92,11 @@ export type NewInvite = Insertable<InvitesTable>;
|
|
|
92
92
|
export type NewMail = Insertable<MailTable>;
|
|
93
93
|
export type NewOneTimeKey = Insertable<OneTimeKeysTable>;
|
|
94
94
|
|
|
95
|
+
export type NewPasskey = Insertable<PasskeysTable>;
|
|
95
96
|
export type NewPermission = Insertable<PermissionsTable>;
|
|
96
97
|
export type NewPreKey = Insertable<PreKeysTable>;
|
|
97
|
-
export type NewServer = Insertable<ServersTable>;
|
|
98
98
|
|
|
99
|
+
export type NewServer = Insertable<ServersTable>;
|
|
99
100
|
export type NewServiceMetric = Insertable<ServiceMetricsTable>;
|
|
100
101
|
export type NewUser = Insertable<UsersTable>;
|
|
101
102
|
export type OneTimeKeyRow = Selectable<OneTimeKeysTable>;
|
|
@@ -109,6 +110,21 @@ export interface OneTimeKeysTable {
|
|
|
109
110
|
userID: string;
|
|
110
111
|
}
|
|
111
112
|
export type OneTimeKeyUpdate = Updateable<OneTimeKeysTable>;
|
|
113
|
+
export type PasskeyRow = Selectable<PasskeysTable>;
|
|
114
|
+
|
|
115
|
+
export interface PasskeysTable {
|
|
116
|
+
algorithm: number;
|
|
117
|
+
createdAt: string;
|
|
118
|
+
credentialID: string;
|
|
119
|
+
lastUsedAt: null | string;
|
|
120
|
+
name: string;
|
|
121
|
+
passkeyID: string;
|
|
122
|
+
publicKey: string;
|
|
123
|
+
signCount: number;
|
|
124
|
+
transports: string;
|
|
125
|
+
userID: string;
|
|
126
|
+
}
|
|
127
|
+
export type PasskeyUpdate = Updateable<PasskeysTable>;
|
|
112
128
|
export type PermissionRow = Selectable<PermissionsTable>;
|
|
113
129
|
|
|
114
130
|
export interface PermissionsTable {
|
|
@@ -138,6 +154,7 @@ export interface ServerDatabase {
|
|
|
138
154
|
invites: InvitesTable;
|
|
139
155
|
mail: MailTable;
|
|
140
156
|
oneTimeKeys: OneTimeKeysTable;
|
|
157
|
+
passkeys: PasskeysTable;
|
|
141
158
|
permissions: PermissionsTable;
|
|
142
159
|
preKeys: PreKeysTable;
|
|
143
160
|
servers: ServersTable;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Kysely } from "kysely";
|
|
8
|
+
|
|
9
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
10
|
+
await db.schema.dropTable("passkeys").ifExists().execute();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Passkeys are an administrative second-class credential alongside
|
|
14
|
+
// `devices`. They cannot send/decrypt mail (no ratchet keys), but they
|
|
15
|
+
// can authenticate the owning user, list devices, delete devices, and
|
|
16
|
+
// approve/reject pending device-enrollment requests — i.e. account
|
|
17
|
+
// recovery and provisioning.
|
|
18
|
+
//
|
|
19
|
+
// `credentialID` is the WebAuthn credential id (base64url, opaque), and
|
|
20
|
+
// is unique across all passkeys. `publicKey` is the COSE_Key bytes
|
|
21
|
+
// returned by the authenticator, hex-encoded for storage. `signCount`
|
|
22
|
+
// is the WebAuthn signature counter (monotonic) used to detect cloned
|
|
23
|
+
// authenticators. `transports` is a comma-separated list of hints
|
|
24
|
+
// ("usb,nfc,ble,internal,hybrid") so subsequent assertions can request
|
|
25
|
+
// the right transport.
|
|
26
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
27
|
+
await db.schema
|
|
28
|
+
.createTable("passkeys")
|
|
29
|
+
.ifNotExists()
|
|
30
|
+
.addColumn("passkeyID", "varchar(255)", (cb) => cb.primaryKey())
|
|
31
|
+
.addColumn("userID", "varchar(255)", (cb) => cb.notNull())
|
|
32
|
+
.addColumn("name", "varchar(255)", (cb) => cb.notNull())
|
|
33
|
+
.addColumn("credentialID", "varchar(512)", (cb) =>
|
|
34
|
+
cb.unique().notNull(),
|
|
35
|
+
)
|
|
36
|
+
.addColumn("publicKey", "text", (cb) => cb.notNull())
|
|
37
|
+
.addColumn("algorithm", "integer", (cb) => cb.notNull())
|
|
38
|
+
.addColumn("signCount", "integer", (cb) => cb.notNull().defaultTo(0))
|
|
39
|
+
.addColumn("transports", "varchar(255)", (cb) =>
|
|
40
|
+
cb.notNull().defaultTo(""),
|
|
41
|
+
)
|
|
42
|
+
.addColumn("createdAt", "text", (cb) => cb.notNull())
|
|
43
|
+
.addColumn("lastUsedAt", "text")
|
|
44
|
+
.execute();
|
|
45
|
+
|
|
46
|
+
await db.schema
|
|
47
|
+
.createIndex("passkeys_userID_idx")
|
|
48
|
+
.ifNotExists()
|
|
49
|
+
.on("passkeys")
|
|
50
|
+
.column("userID")
|
|
51
|
+
.execute();
|
|
52
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ import { errorHandler } from "./errors.ts";
|
|
|
35
35
|
import { getFileRouter } from "./file.ts";
|
|
36
36
|
import { getInviteRouter } from "./invite.ts";
|
|
37
37
|
import { setupDocs } from "./openapi.ts";
|
|
38
|
+
import { getPasskeyRouter } from "./passkey.ts";
|
|
39
|
+
import { getPasskeyDeviceRouter } from "./passkeyDevices.ts";
|
|
38
40
|
import {
|
|
39
41
|
hasAnyPermission,
|
|
40
42
|
hasPermission,
|
|
@@ -95,6 +97,14 @@ const jwtDevicePayload = z.object({
|
|
|
95
97
|
}),
|
|
96
98
|
});
|
|
97
99
|
|
|
100
|
+
const jwtPasskeyPayload = z.object({
|
|
101
|
+
passkey: z.object({
|
|
102
|
+
passkeyID: z.string(),
|
|
103
|
+
}),
|
|
104
|
+
scope: z.literal("passkey"),
|
|
105
|
+
user: UserSchema,
|
|
106
|
+
});
|
|
107
|
+
|
|
98
108
|
/** Extract Bearer token from Authorization header. */
|
|
99
109
|
function extractBearer(req: express.Request): null | string {
|
|
100
110
|
const header = req.headers.authorization;
|
|
@@ -107,13 +117,27 @@ const checkAuth: express.RequestHandler = (req, _res, next) => {
|
|
|
107
117
|
if (token) {
|
|
108
118
|
try {
|
|
109
119
|
const result = jwt.verify(token, getJwtSecret());
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
// Passkey-scoped JWTs share the bearer slot with regular
|
|
121
|
+
// user JWTs but carry a `scope: "passkey"` discriminator
|
|
122
|
+
// and a `passkey` claim. Populate `req.passkey` and the
|
|
123
|
+
// user (so route guards using `req.user` still pass) but
|
|
124
|
+
// never `req.device`. The actual privilege gate lives at
|
|
125
|
+
// the route level: passkey-only routes require
|
|
126
|
+
// `req.passkey`, device-only routes require `req.device`.
|
|
127
|
+
const passkeyParsed = jwtPasskeyPayload.safeParse(result);
|
|
128
|
+
if (passkeyParsed.success) {
|
|
129
|
+
req.user = passkeyParsed.data.user;
|
|
130
|
+
req.passkey = passkeyParsed.data.passkey;
|
|
116
131
|
req.bearerToken = token;
|
|
132
|
+
} else {
|
|
133
|
+
const parsed = jwtUserPayload.safeParse(result);
|
|
134
|
+
if (parsed.success) {
|
|
135
|
+
req.user = parsed.data.user;
|
|
136
|
+
if (parsed.data.exp !== undefined) {
|
|
137
|
+
req.exp = parsed.data.exp;
|
|
138
|
+
}
|
|
139
|
+
req.bearerToken = token;
|
|
140
|
+
}
|
|
117
141
|
}
|
|
118
142
|
} catch {
|
|
119
143
|
// Token verification failed — continue without auth
|
|
@@ -147,6 +171,21 @@ export const protect: express.RequestHandler = (req, res, next) => {
|
|
|
147
171
|
next();
|
|
148
172
|
};
|
|
149
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Restrict a route to passkey-scoped JWTs (used by the parallel
|
|
176
|
+
* `/user/:id/passkey/devices/...` admin/recovery routes). A
|
|
177
|
+
* passkey-authenticated caller can list devices, delete devices, and
|
|
178
|
+
* approve/reject pending enrollments — and nothing else.
|
|
179
|
+
*/
|
|
180
|
+
export const protectPasskey: express.RequestHandler = (req, res, next) => {
|
|
181
|
+
if (!req.user || !req.passkey) {
|
|
182
|
+
res.sendStatus(401);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
next();
|
|
187
|
+
};
|
|
188
|
+
|
|
150
189
|
export const msgpackParser: express.RequestHandler = (req, res, next) => {
|
|
151
190
|
if (req.is("application/msgpack")) {
|
|
152
191
|
try {
|
|
@@ -185,6 +224,8 @@ export const initApp = (
|
|
|
185
224
|
const fileRouter = getFileRouter(db);
|
|
186
225
|
const avatarRouter = getAvatarRouter();
|
|
187
226
|
const inviteRouter = getInviteRouter(db, tokenValidator, notify);
|
|
227
|
+
const passkeyRouter = getPasskeyRouter(db);
|
|
228
|
+
const passkeyDeviceRouter = getPasskeyDeviceRouter(db, notify);
|
|
188
229
|
|
|
189
230
|
// MIDDLEWARE
|
|
190
231
|
// Global per-IP rate limit is the FIRST middleware so a flooded
|
|
@@ -854,6 +895,14 @@ export const initApp = (
|
|
|
854
895
|
);
|
|
855
896
|
|
|
856
897
|
// COMPLEX RESOURCES
|
|
898
|
+
// Passkey routes are mounted at the root since they live under
|
|
899
|
+
// both `/user/:id/passkeys/...` (registration / list / delete by
|
|
900
|
+
// an authenticated device) and `/auth/passkey/...` (public
|
|
901
|
+
// sign-in). The router itself defines the full path on each
|
|
902
|
+
// route handler.
|
|
903
|
+
api.use(passkeyRouter);
|
|
904
|
+
api.use(passkeyDeviceRouter);
|
|
905
|
+
|
|
857
906
|
api.use("/user", userRouter);
|
|
858
907
|
|
|
859
908
|
api.use("/file", fileRouter);
|