@vex-chat/spire 1.3.7 → 1.5.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 +4 -0
- 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 +63 -9
- 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 +26 -1
- package/dist/server/user.js +84 -0
- package/dist/server/user.js.map +1 -1
- package/dist/server/wellKnown.d.ts +57 -0
- package/dist/server/wellKnown.js +122 -0
- package/dist/server/wellKnown.js.map +1 -0
- 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/__tests__/wellKnown.spec.ts +113 -0
- package/src/db/schema.ts +18 -1
- package/src/migrations/2026-05-03_passkeys.ts +52 -0
- package/src/server/index.ts +68 -9
- package/src/server/passkey.ts +589 -0
- package/src/server/passkeyDevices.ts +154 -0
- package/src/server/user.ts +105 -1
- package/src/server/wellKnown.ts +144 -0
- package/src/types/express.ts +8 -0
|
@@ -0,0 +1,217 @@
|
|
|
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 { SpireOptions } from "../Spire.ts";
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { Database } from "../Database.ts";
|
|
12
|
+
|
|
13
|
+
vi.mock("uuid", () => ({
|
|
14
|
+
parse: (s: string) => {
|
|
15
|
+
const matches = s.replace(/-/g, "").match(/.{2}/g);
|
|
16
|
+
if (!matches) throw new Error("Invalid UUID");
|
|
17
|
+
return Uint8Array.from(matches.map((b) => parseInt(b, 16)));
|
|
18
|
+
},
|
|
19
|
+
stringify: (b: Uint8Array) => {
|
|
20
|
+
const hex = Array.from(b)
|
|
21
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
22
|
+
.join("");
|
|
23
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
24
|
+
},
|
|
25
|
+
v4: vi.fn(() => "93ce482b-a0f2-4f6e-b1df-3aed61073552"),
|
|
26
|
+
validate: () => true,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const options: SpireOptions = {
|
|
30
|
+
dbType: "sqlite3mem",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const userID = "4e67b90f-cbf8-44bc-8ce3-d3b248f033f1";
|
|
34
|
+
|
|
35
|
+
const samplePasskey = {
|
|
36
|
+
algorithm: -7,
|
|
37
|
+
credentialID: "credential-id-base64url",
|
|
38
|
+
name: "Yubikey 5C",
|
|
39
|
+
publicKeyHex:
|
|
40
|
+
"30c2d0294c1cfdbb73c6b3bbe6010088c2dba8384b04ff2e2b92172431d66b5e",
|
|
41
|
+
transports: ["usb", "nfc"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function withDb<T>(fn: (db: Database) => Promise<T>): Promise<T> {
|
|
45
|
+
const provider = new Database(options);
|
|
46
|
+
return new Promise<T>((resolve, reject) => {
|
|
47
|
+
provider.once("ready", () => {
|
|
48
|
+
void (async () => {
|
|
49
|
+
try {
|
|
50
|
+
const result = await fn(provider);
|
|
51
|
+
await provider.close();
|
|
52
|
+
resolve(result);
|
|
53
|
+
} catch (e: unknown) {
|
|
54
|
+
await provider.close().catch(() => {
|
|
55
|
+
// best-effort cleanup; ignore close failures here
|
|
56
|
+
// so we surface the original error instead.
|
|
57
|
+
});
|
|
58
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("Database passkeys", () => {
|
|
66
|
+
describe("createPasskey", () => {
|
|
67
|
+
it("inserts a passkey and returns the public shape (no key material)", async () => {
|
|
68
|
+
expect.assertions(7);
|
|
69
|
+
await withDb(async (db) => {
|
|
70
|
+
const created = await db.createPasskey(
|
|
71
|
+
userID,
|
|
72
|
+
samplePasskey.name,
|
|
73
|
+
samplePasskey.credentialID,
|
|
74
|
+
samplePasskey.publicKeyHex,
|
|
75
|
+
samplePasskey.algorithm,
|
|
76
|
+
samplePasskey.transports,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(created.userID).toBe(userID);
|
|
80
|
+
expect(created.name).toBe(samplePasskey.name);
|
|
81
|
+
expect(created.transports).toEqual(samplePasskey.transports);
|
|
82
|
+
expect(created.lastUsedAt).toBeNull();
|
|
83
|
+
expect(typeof created.passkeyID).toBe("string");
|
|
84
|
+
expect(created.passkeyID.length).toBeGreaterThan(0);
|
|
85
|
+
// Internal fields should NOT be on the public shape
|
|
86
|
+
expect(
|
|
87
|
+
(created as unknown as Record<string, unknown>)[
|
|
88
|
+
"publicKey"
|
|
89
|
+
],
|
|
90
|
+
).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("retrievePasskeyByCredentialID", () => {
|
|
96
|
+
it("returns the row including key material when looked up by credentialID", async () => {
|
|
97
|
+
expect.assertions(4);
|
|
98
|
+
await withDb(async (db) => {
|
|
99
|
+
await db.createPasskey(
|
|
100
|
+
userID,
|
|
101
|
+
samplePasskey.name,
|
|
102
|
+
samplePasskey.credentialID,
|
|
103
|
+
samplePasskey.publicKeyHex,
|
|
104
|
+
samplePasskey.algorithm,
|
|
105
|
+
samplePasskey.transports,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const row = await db.retrievePasskeyByCredentialID(
|
|
109
|
+
samplePasskey.credentialID,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(row).not.toBeNull();
|
|
113
|
+
if (row === null) return;
|
|
114
|
+
expect(row.publicKey).toBe(samplePasskey.publicKeyHex);
|
|
115
|
+
expect(row.algorithm).toBe(samplePasskey.algorithm);
|
|
116
|
+
expect(row.signCount).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns null for an unknown credentialID", async () => {
|
|
121
|
+
expect.assertions(1);
|
|
122
|
+
await withDb(async (db) => {
|
|
123
|
+
const row =
|
|
124
|
+
await db.retrievePasskeyByCredentialID("does-not-exist");
|
|
125
|
+
expect(row).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("retrievePasskeysByUser", () => {
|
|
131
|
+
it("returns all passkeys for the user in public shape", async () => {
|
|
132
|
+
expect.assertions(3);
|
|
133
|
+
await withDb(async (db) => {
|
|
134
|
+
await db.createPasskey(
|
|
135
|
+
userID,
|
|
136
|
+
"yubikey",
|
|
137
|
+
"cred-1",
|
|
138
|
+
samplePasskey.publicKeyHex,
|
|
139
|
+
-7,
|
|
140
|
+
["usb"],
|
|
141
|
+
);
|
|
142
|
+
await db.createPasskey(
|
|
143
|
+
userID,
|
|
144
|
+
"iCloud",
|
|
145
|
+
"cred-2",
|
|
146
|
+
samplePasskey.publicKeyHex,
|
|
147
|
+
-7,
|
|
148
|
+
[],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const list = await db.retrievePasskeysByUser(userID);
|
|
152
|
+
expect(list).toHaveLength(2);
|
|
153
|
+
const names = list.map((p) => p.name).sort();
|
|
154
|
+
expect(names).toEqual(["iCloud", "yubikey"]);
|
|
155
|
+
// empty transports column should decode to []
|
|
156
|
+
const icloud = list.find((p) => p.name === "iCloud");
|
|
157
|
+
expect(icloud?.transports).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns an empty array for users with no passkeys", async () => {
|
|
162
|
+
expect.assertions(1);
|
|
163
|
+
await withDb(async (db) => {
|
|
164
|
+
const list = await db.retrievePasskeysByUser(userID);
|
|
165
|
+
expect(list).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("markPasskeyUsed", () => {
|
|
171
|
+
it("bumps the signature counter and lastUsedAt timestamp", async () => {
|
|
172
|
+
expect.assertions(3);
|
|
173
|
+
await withDb(async (db) => {
|
|
174
|
+
const created = await db.createPasskey(
|
|
175
|
+
userID,
|
|
176
|
+
samplePasskey.name,
|
|
177
|
+
samplePasskey.credentialID,
|
|
178
|
+
samplePasskey.publicKeyHex,
|
|
179
|
+
samplePasskey.algorithm,
|
|
180
|
+
samplePasskey.transports,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
await db.markPasskeyUsed(created.passkeyID, 42);
|
|
184
|
+
const row = await db.retrievePasskeyInternal(created.passkeyID);
|
|
185
|
+
|
|
186
|
+
expect(row).not.toBeNull();
|
|
187
|
+
if (row === null) return;
|
|
188
|
+
expect(row.signCount).toBe(42);
|
|
189
|
+
expect(row.lastUsedAt).not.toBeNull();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("deletePasskey", () => {
|
|
195
|
+
it("removes the passkey row", async () => {
|
|
196
|
+
expect.assertions(2);
|
|
197
|
+
await withDb(async (db) => {
|
|
198
|
+
const created = await db.createPasskey(
|
|
199
|
+
userID,
|
|
200
|
+
samplePasskey.name,
|
|
201
|
+
samplePasskey.credentialID,
|
|
202
|
+
samplePasskey.publicKeyHex,
|
|
203
|
+
samplePasskey.algorithm,
|
|
204
|
+
samplePasskey.transports,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await db.deletePasskey(created.passkeyID);
|
|
208
|
+
|
|
209
|
+
const row = await db.retrievePasskeyInternal(created.passkeyID);
|
|
210
|
+
expect(row).toBeNull();
|
|
211
|
+
|
|
212
|
+
const list = await db.retrievePasskeysByUser(userID);
|
|
213
|
+
expect(list).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
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 { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildAppleAppSiteAssociation,
|
|
11
|
+
buildAssetLinks,
|
|
12
|
+
normalizeFingerprint,
|
|
13
|
+
} from "../server/wellKnown.ts";
|
|
14
|
+
|
|
15
|
+
const PASSKEY_ENV_VARS = [
|
|
16
|
+
"SPIRE_PASSKEY_IOS_APP_IDS",
|
|
17
|
+
"SPIRE_PASSKEY_ANDROID_PACKAGE",
|
|
18
|
+
"SPIRE_PASSKEY_ANDROID_FINGERPRINTS",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const v of PASSKEY_ENV_VARS) {
|
|
23
|
+
process.env[v] = "";
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
25
|
+
delete process.env[v];
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("normalizeFingerprint", () => {
|
|
30
|
+
it("normalizes lower-case hex without colons", () => {
|
|
31
|
+
const out = normalizeFingerprint(
|
|
32
|
+
"7d94dbdfc92da8bba1a79b64e117ad700a69daa5a0eb00a912a44efc5f6b7c42",
|
|
33
|
+
);
|
|
34
|
+
expect(out).toBe(
|
|
35
|
+
"7D:94:DB:DF:C9:2D:A8:BB:A1:A7:9B:64:E1:17:AD:70:0A:69:DA:A5:A0:EB:00:A9:12:A4:4E:FC:5F:6B:7C:42",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("preserves a correctly-formatted colon-separated value", () => {
|
|
40
|
+
const input =
|
|
41
|
+
"7D:94:DB:DF:C9:2D:A8:BB:A1:A7:9B:64:E1:17:AD:70:0A:69:DA:A5:A0:EB:00:A9:12:A4:4E:FC:5F:6B:7C:42";
|
|
42
|
+
expect(normalizeFingerprint(input)).toBe(input);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects values that are too short", () => {
|
|
46
|
+
expect(normalizeFingerprint("AB:CD:EF")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects non-hex characters", () => {
|
|
50
|
+
expect(
|
|
51
|
+
normalizeFingerprint(
|
|
52
|
+
"ZZ:94:DB:DF:C9:2D:A8:BB:A1:A7:9B:64:E1:17:AD:70:0A:69:DA:A5:A0:EB:00:A9:12:A4:4E:FC:5F:6B:7C:42",
|
|
53
|
+
),
|
|
54
|
+
).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("buildAppleAppSiteAssociation", () => {
|
|
59
|
+
it("returns null when SPIRE_PASSKEY_IOS_APP_IDS is unset", () => {
|
|
60
|
+
expect(buildAppleAppSiteAssociation()).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns the AASA body with parsed app IDs", () => {
|
|
64
|
+
process.env["SPIRE_PASSKEY_IOS_APP_IDS"] =
|
|
65
|
+
"ABCDE12345.chat.vex.mobile, ABCDE12345.chat.vex.mobile.dev";
|
|
66
|
+
expect(buildAppleAppSiteAssociation()).toEqual({
|
|
67
|
+
webcredentials: {
|
|
68
|
+
apps: [
|
|
69
|
+
"ABCDE12345.chat.vex.mobile",
|
|
70
|
+
"ABCDE12345.chat.vex.mobile.dev",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("buildAssetLinks", () => {
|
|
78
|
+
it("returns null when package or fingerprints are unset", () => {
|
|
79
|
+
expect(buildAssetLinks()).toBeNull();
|
|
80
|
+
|
|
81
|
+
process.env["SPIRE_PASSKEY_ANDROID_PACKAGE"] = "chat.vex.mobile";
|
|
82
|
+
expect(buildAssetLinks()).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns the asset-links body with normalized fingerprints", () => {
|
|
86
|
+
process.env["SPIRE_PASSKEY_ANDROID_PACKAGE"] = "chat.vex.mobile";
|
|
87
|
+
process.env["SPIRE_PASSKEY_ANDROID_FINGERPRINTS"] =
|
|
88
|
+
"7d94dbdfc92da8bba1a79b64e117ad700a69daa5a0eb00a912a44efc5f6b7c42, 5C:13:F0:E4:EE:11:67:A4:9C:04:EB:C8:03:3E:05:8F:44:50:BD:AE:36:AA:15:B6:4F:83:7C:AC:24:0F:D2:82";
|
|
89
|
+
const body = buildAssetLinks();
|
|
90
|
+
expect(body).toEqual([
|
|
91
|
+
{
|
|
92
|
+
relation: [
|
|
93
|
+
"delegate_permission/common.get_login_creds",
|
|
94
|
+
"delegate_permission/common.handle_all_urls",
|
|
95
|
+
],
|
|
96
|
+
target: {
|
|
97
|
+
namespace: "android_app",
|
|
98
|
+
package_name: "chat.vex.mobile",
|
|
99
|
+
sha256_cert_fingerprints: [
|
|
100
|
+
"7D:94:DB:DF:C9:2D:A8:BB:A1:A7:9B:64:E1:17:AD:70:0A:69:DA:A5:A0:EB:00:A9:12:A4:4E:FC:5F:6B:7C:42",
|
|
101
|
+
"5C:13:F0:E4:EE:11:67:A4:9C:04:EB:C8:03:3E:05:8F:44:50:BD:AE:36:AA:15:B6:4F:83:7C:AC:24:0F:D2:82",
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("drops malformed fingerprints and returns null when none survive", () => {
|
|
109
|
+
process.env["SPIRE_PASSKEY_ANDROID_PACKAGE"] = "chat.vex.mobile";
|
|
110
|
+
process.env["SPIRE_PASSKEY_ANDROID_FINGERPRINTS"] = "not-a-hash, ZZ:CD";
|
|
111
|
+
expect(buildAssetLinks()).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
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,
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
import { globalLimiter } from "./rateLimit.ts";
|
|
44
46
|
import { getUserRouter } from "./user.ts";
|
|
45
47
|
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
48
|
+
import { getWellKnownRouter } from "./wellKnown.ts";
|
|
46
49
|
|
|
47
50
|
// expiry of regkeys
|
|
48
51
|
export const EXPIRY_TIME = 1000 * 60 * 5;
|
|
@@ -95,6 +98,14 @@ const jwtDevicePayload = z.object({
|
|
|
95
98
|
}),
|
|
96
99
|
});
|
|
97
100
|
|
|
101
|
+
const jwtPasskeyPayload = z.object({
|
|
102
|
+
passkey: z.object({
|
|
103
|
+
passkeyID: z.string(),
|
|
104
|
+
}),
|
|
105
|
+
scope: z.literal("passkey"),
|
|
106
|
+
user: UserSchema,
|
|
107
|
+
});
|
|
108
|
+
|
|
98
109
|
/** Extract Bearer token from Authorization header. */
|
|
99
110
|
function extractBearer(req: express.Request): null | string {
|
|
100
111
|
const header = req.headers.authorization;
|
|
@@ -107,13 +118,27 @@ const checkAuth: express.RequestHandler = (req, _res, next) => {
|
|
|
107
118
|
if (token) {
|
|
108
119
|
try {
|
|
109
120
|
const result = jwt.verify(token, getJwtSecret());
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
// Passkey-scoped JWTs share the bearer slot with regular
|
|
122
|
+
// user JWTs but carry a `scope: "passkey"` discriminator
|
|
123
|
+
// and a `passkey` claim. Populate `req.passkey` and the
|
|
124
|
+
// user (so route guards using `req.user` still pass) but
|
|
125
|
+
// never `req.device`. The actual privilege gate lives at
|
|
126
|
+
// the route level: passkey-only routes require
|
|
127
|
+
// `req.passkey`, device-only routes require `req.device`.
|
|
128
|
+
const passkeyParsed = jwtPasskeyPayload.safeParse(result);
|
|
129
|
+
if (passkeyParsed.success) {
|
|
130
|
+
req.user = passkeyParsed.data.user;
|
|
131
|
+
req.passkey = passkeyParsed.data.passkey;
|
|
116
132
|
req.bearerToken = token;
|
|
133
|
+
} else {
|
|
134
|
+
const parsed = jwtUserPayload.safeParse(result);
|
|
135
|
+
if (parsed.success) {
|
|
136
|
+
req.user = parsed.data.user;
|
|
137
|
+
if (parsed.data.exp !== undefined) {
|
|
138
|
+
req.exp = parsed.data.exp;
|
|
139
|
+
}
|
|
140
|
+
req.bearerToken = token;
|
|
141
|
+
}
|
|
117
142
|
}
|
|
118
143
|
} catch {
|
|
119
144
|
// Token verification failed — continue without auth
|
|
@@ -147,6 +172,21 @@ export const protect: express.RequestHandler = (req, res, next) => {
|
|
|
147
172
|
next();
|
|
148
173
|
};
|
|
149
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Restrict a route to passkey-scoped JWTs (used by the parallel
|
|
177
|
+
* `/user/:id/passkey/devices/...` admin/recovery routes). A
|
|
178
|
+
* passkey-authenticated caller can list devices, delete devices, and
|
|
179
|
+
* approve/reject pending enrollments — and nothing else.
|
|
180
|
+
*/
|
|
181
|
+
export const protectPasskey: express.RequestHandler = (req, res, next) => {
|
|
182
|
+
if (!req.user || !req.passkey) {
|
|
183
|
+
res.sendStatus(401);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
next();
|
|
188
|
+
};
|
|
189
|
+
|
|
150
190
|
export const msgpackParser: express.RequestHandler = (req, res, next) => {
|
|
151
191
|
if (req.is("application/msgpack")) {
|
|
152
192
|
try {
|
|
@@ -185,11 +225,22 @@ export const initApp = (
|
|
|
185
225
|
const fileRouter = getFileRouter(db);
|
|
186
226
|
const avatarRouter = getAvatarRouter();
|
|
187
227
|
const inviteRouter = getInviteRouter(db, tokenValidator, notify);
|
|
228
|
+
const passkeyRouter = getPasskeyRouter(db);
|
|
229
|
+
const passkeyDeviceRouter = getPasskeyDeviceRouter(db, notify);
|
|
188
230
|
|
|
189
231
|
// MIDDLEWARE
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
232
|
+
|
|
233
|
+
// WebAuthn well-known association files (AASA, assetlinks.json)
|
|
234
|
+
// are served BEFORE the global rate limiter so periodic platform
|
|
235
|
+
// fetches by Apple / Google CDNs are never 429'd. They 404 when
|
|
236
|
+
// the relevant env vars aren't set, so this is a no-op for
|
|
237
|
+
// deployments that haven't enrolled an app.
|
|
238
|
+
api.use(getWellKnownRouter());
|
|
239
|
+
|
|
240
|
+
// Global per-IP rate limit is the FIRST authenticated-path
|
|
241
|
+
// middleware so a flooded source hits the limiter before Express
|
|
242
|
+
// spends any cycles on body parsing, helmet, or auth. See
|
|
243
|
+
// src/server/rateLimit.ts.
|
|
193
244
|
api.use(globalLimiter);
|
|
194
245
|
api.use(express.json({ limit: "20mb" }));
|
|
195
246
|
api.use(
|
|
@@ -854,6 +905,14 @@ export const initApp = (
|
|
|
854
905
|
);
|
|
855
906
|
|
|
856
907
|
// COMPLEX RESOURCES
|
|
908
|
+
// Passkey routes are mounted at the root since they live under
|
|
909
|
+
// both `/user/:id/passkeys/...` (registration / list / delete by
|
|
910
|
+
// an authenticated device) and `/auth/passkey/...` (public
|
|
911
|
+
// sign-in). The router itself defines the full path on each
|
|
912
|
+
// route handler.
|
|
913
|
+
api.use(passkeyRouter);
|
|
914
|
+
api.use(passkeyDeviceRouter);
|
|
915
|
+
|
|
857
916
|
api.use("/user", userRouter);
|
|
858
917
|
|
|
859
918
|
api.use("/file", fileRouter);
|