@babylonjs-toolkit/server 0.0.0-stage → 9.35.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/CUSTOM.md +1690 -0
- package/DEPLOY.md +2012 -0
- package/README.md +1081 -2
- package/lib/AbuseLimits.d.ts +64 -0
- package/lib/AbuseLimits.js +121 -0
- package/lib/Authentication.d.ts +86 -0
- package/lib/Authentication.js +272 -0
- package/lib/BootSessionOwner.d.ts +86 -0
- package/lib/BootSessionOwner.js +116 -0
- package/lib/ColyseusLoggerAdapter.d.ts +32 -0
- package/lib/ColyseusLoggerAdapter.js +73 -0
- package/lib/DedicatedSceneRegistry.d.ts +52 -0
- package/lib/DedicatedSceneRegistry.js +74 -0
- package/lib/DedicatedSession.d.ts +152 -0
- package/lib/DedicatedSession.js +173 -0
- package/lib/DualTransport.d.ts +112 -0
- package/lib/DualTransport.js +197 -0
- package/lib/GameServer.d.ts +64 -0
- package/lib/GameServer.js +647 -0
- package/lib/GameServerConfig.d.ts +387 -0
- package/lib/GameServerConfig.js +1059 -0
- package/lib/GameServerLobbyRoom.d.ts +33 -0
- package/lib/GameServerLobbyRoom.js +56 -0
- package/lib/GameServerLogger.d.ts +111 -0
- package/lib/GameServerLogger.js +190 -0
- package/lib/GameServerMetrics.d.ts +158 -0
- package/lib/GameServerMetrics.js +276 -0
- package/lib/GameServerState.d.ts +164 -0
- package/lib/GameServerState.js +26 -0
- package/lib/HeadlessRuntime.d.ts +316 -0
- package/lib/HeadlessRuntime.js +380 -0
- package/lib/JoinGate.d.ts +34 -0
- package/lib/JoinGate.js +221 -0
- package/lib/NetworkAddresses.d.ts +33 -0
- package/lib/NetworkAddresses.js +90 -0
- package/lib/PlayerTokens.d.ts +17 -0
- package/lib/PlayerTokens.js +24 -0
- package/lib/RoomServerTransport.d.ts +73 -0
- package/lib/RoomServerTransport.js +90 -0
- package/lib/ServerEndpoints.d.ts +133 -0
- package/lib/ServerEndpoints.js +247 -0
- package/lib/ServerInit.d.ts +28 -0
- package/lib/ServerInit.js +113 -0
- package/lib/SessionRoom.d.ts +391 -0
- package/lib/SessionRoom.js +930 -0
- package/lib/TlsCertificates.d.ts +35 -0
- package/lib/TlsCertificates.js +130 -0
- package/lib/admin/AccountRoutes.d.ts +8 -0
- package/lib/admin/AccountRoutes.js +502 -0
- package/lib/admin/AdminAccounts.d.ts +329 -0
- package/lib/admin/AdminAccounts.js +607 -0
- package/lib/admin/AdminApiRouter.d.ts +202 -0
- package/lib/admin/AdminApiRouter.js +465 -0
- package/lib/admin/AdminApiTypes.d.ts +1782 -0
- package/lib/admin/AdminApiTypes.js +4 -0
- package/lib/admin/AdminAudit.d.ts +113 -0
- package/lib/admin/AdminAudit.js +266 -0
- package/lib/admin/AdminCli.d.ts +7 -0
- package/lib/admin/AdminCli.js +351 -0
- package/lib/admin/AdminClock.d.ts +51 -0
- package/lib/admin/AdminClock.js +10 -0
- package/lib/admin/AdminConsole.d.ts +39 -0
- package/lib/admin/AdminConsole.js +325 -0
- package/lib/admin/AdminConstants.d.ts +147 -0
- package/lib/admin/AdminConstants.js +167 -0
- package/lib/admin/AdminCrypto.d.ts +19 -0
- package/lib/admin/AdminCrypto.js +29 -0
- package/lib/admin/AdminExtensionsHost.d.ts +176 -0
- package/lib/admin/AdminExtensionsHost.js +307 -0
- package/lib/admin/AdminPasswords.d.ts +33 -0
- package/lib/admin/AdminPasswords.js +92 -0
- package/lib/admin/AdminPermissions.d.ts +28 -0
- package/lib/admin/AdminPermissions.js +74 -0
- package/lib/admin/AdminRecords.d.ts +196 -0
- package/lib/admin/AdminRecords.js +3 -0
- package/lib/admin/AdminRequestBodies.d.ts +47 -0
- package/lib/admin/AdminRequestBodies.js +84 -0
- package/lib/admin/AdminRequestGate.d.ts +38 -0
- package/lib/admin/AdminRequestGate.js +59 -0
- package/lib/admin/AdminResponses.d.ts +103 -0
- package/lib/admin/AdminResponses.js +164 -0
- package/lib/admin/AdminServices.d.ts +99 -0
- package/lib/admin/AdminServices.js +4 -0
- package/lib/admin/AdminStaticFiles.d.ts +29 -0
- package/lib/admin/AdminStaticFiles.js +97 -0
- package/lib/admin/AdminStore.d.ts +152 -0
- package/lib/admin/AdminStore.js +46 -0
- package/lib/admin/AlertChannels.d.ts +43 -0
- package/lib/admin/AlertChannels.js +120 -0
- package/lib/admin/AlertEngine.d.ts +231 -0
- package/lib/admin/AlertEngine.js +681 -0
- package/lib/admin/AlertRoutes.d.ts +8 -0
- package/lib/admin/AlertRoutes.js +180 -0
- package/lib/admin/ApiKeyRoutes.d.ts +8 -0
- package/lib/admin/ApiKeyRoutes.js +53 -0
- package/lib/admin/ApiKeys.d.ts +63 -0
- package/lib/admin/ApiKeys.js +167 -0
- package/lib/admin/AuditRoutes.d.ts +8 -0
- package/lib/admin/AuditRoutes.js +106 -0
- package/lib/admin/AuthToolRoutes.d.ts +13 -0
- package/lib/admin/AuthToolRoutes.js +164 -0
- package/lib/admin/BanList.d.ts +87 -0
- package/lib/admin/BanList.js +152 -0
- package/lib/admin/CborDecoder.d.ts +18 -0
- package/lib/admin/CborDecoder.js +201 -0
- package/lib/admin/ColyseusCloudClient.d.ts +313 -0
- package/lib/admin/ColyseusCloudClient.js +272 -0
- package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
- package/lib/admin/ColyseusCloudRoutes.js +91 -0
- package/lib/admin/ColyseusCloudService.d.ts +180 -0
- package/lib/admin/ColyseusCloudService.js +476 -0
- package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
- package/lib/admin/ColyseusCloudSettings.js +29 -0
- package/lib/admin/ColyseusCloudViews.d.ts +105 -0
- package/lib/admin/ColyseusCloudViews.js +371 -0
- package/lib/admin/CommonPasswords.d.ts +6 -0
- package/lib/admin/CommonPasswords.js +19 -0
- package/lib/admin/ConfigOverrides.d.ts +174 -0
- package/lib/admin/ConfigOverrides.js +406 -0
- package/lib/admin/ConfigRoutes.d.ts +8 -0
- package/lib/admin/ConfigRoutes.js +91 -0
- package/lib/admin/ConsoleSessions.d.ts +112 -0
- package/lib/admin/ConsoleSessions.js +201 -0
- package/lib/admin/DedicatedRoutes.d.ts +21 -0
- package/lib/admin/DedicatedRoutes.js +254 -0
- package/lib/admin/ExtensionRoutes.d.ts +8 -0
- package/lib/admin/ExtensionRoutes.js +145 -0
- package/lib/admin/FileAdminStore.d.ts +157 -0
- package/lib/admin/FileAdminStore.js +349 -0
- package/lib/admin/FleetDirectory.d.ts +323 -0
- package/lib/admin/FleetDirectory.js +489 -0
- package/lib/admin/FleetLeader.d.ts +43 -0
- package/lib/admin/FleetLeader.js +89 -0
- package/lib/admin/FleetRoutes.d.ts +55 -0
- package/lib/admin/FleetRoutes.js +465 -0
- package/lib/admin/HealthWarnings.d.ts +10 -0
- package/lib/admin/HealthWarnings.js +95 -0
- package/lib/admin/LeaderboardRoutes.d.ts +8 -0
- package/lib/admin/LeaderboardRoutes.js +569 -0
- package/lib/admin/LogBuffer.d.ts +60 -0
- package/lib/admin/LogBuffer.js +92 -0
- package/lib/admin/LogRedaction.d.ts +15 -0
- package/lib/admin/LogRedaction.js +72 -0
- package/lib/admin/LogRoutes.d.ts +45 -0
- package/lib/admin/LogRoutes.js +307 -0
- package/lib/admin/MaintenanceMode.d.ts +41 -0
- package/lib/admin/MaintenanceMode.js +74 -0
- package/lib/admin/MaintenanceWindows.d.ts +91 -0
- package/lib/admin/MaintenanceWindows.js +207 -0
- package/lib/admin/MetricsRoutes.d.ts +8 -0
- package/lib/admin/MetricsRoutes.js +209 -0
- package/lib/admin/MetricsSampler.d.ts +124 -0
- package/lib/admin/MetricsSampler.js +211 -0
- package/lib/admin/OneTimeCodes.d.ts +60 -0
- package/lib/admin/OneTimeCodes.js +162 -0
- package/lib/admin/OpenIdConnect.d.ts +146 -0
- package/lib/admin/OpenIdConnect.js +335 -0
- package/lib/admin/Passkeys.d.ts +130 -0
- package/lib/admin/Passkeys.js +432 -0
- package/lib/admin/PlayerRoutes.d.ts +20 -0
- package/lib/admin/PlayerRoutes.js +676 -0
- package/lib/admin/RedisAdminStore.d.ts +114 -0
- package/lib/admin/RedisAdminStore.js +258 -0
- package/lib/admin/RolloutCoordinator.d.ts +99 -0
- package/lib/admin/RolloutCoordinator.js +321 -0
- package/lib/admin/ServerEvents.d.ts +57 -0
- package/lib/admin/ServerEvents.js +89 -0
- package/lib/admin/SessionRoutes.d.ts +113 -0
- package/lib/admin/SessionRoutes.js +386 -0
- package/lib/admin/UserRoutes.d.ts +8 -0
- package/lib/admin/UserRoutes.js +214 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +221 -0
- package/lib/console/assets/index-CI5N7c-T.js +21 -0
- package/lib/console/assets/index-DPitvzLp.css +1 -0
- package/lib/console/favicon.svg +1 -0
- package/lib/console/index.html +16 -0
- package/lib/discord/DiscordOAuth.d.ts +57 -0
- package/lib/discord/DiscordOAuth.js +127 -0
- package/lib/discord/DiscordSessions.d.ts +79 -0
- package/lib/discord/DiscordSessions.js +135 -0
- package/lib/discord/DiscordSignInConstants.d.ts +54 -0
- package/lib/discord/DiscordSignInConstants.js +55 -0
- package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
- package/lib/discord/DiscordSignInRoutes.js +359 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +18 -0
- package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
- package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
- package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
- package/lib/leaderboards/LeaderboardConstants.js +70 -0
- package/lib/leaderboards/LeaderboardError.d.ts +15 -0
- package/lib/leaderboards/LeaderboardError.js +20 -0
- package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
- package/lib/leaderboards/LeaderboardIdentity.js +72 -0
- package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
- package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
- package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
- package/lib/leaderboards/LeaderboardRankStore.js +18 -0
- package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
- package/lib/leaderboards/LeaderboardRules.js +472 -0
- package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
- package/lib/leaderboards/LeaderboardScheduler.js +103 -0
- package/lib/leaderboards/LeaderboardService.d.ts +669 -0
- package/lib/leaderboards/LeaderboardService.js +1667 -0
- package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
- package/lib/leaderboards/LeaderboardTransfer.js +580 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
- package/package.json +64 -4
- package/templates/init/Caddyfile +6 -0
- package/templates/init/DEPLOY.md +233 -0
- package/templates/init/Dockerfile +12 -0
- package/templates/init/app.json +21 -0
- package/templates/init/aws/README.md +118 -0
- package/templates/init/aws/ecs-fargate-alb.yml +207 -0
- package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
- package/templates/init/babylon-game-server.config.mjs +158 -0
- package/templates/init/colyseus-cloud/README.md +133 -0
- package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
- package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
- package/templates/init/colyseus-cloud/gitignore +6 -0
- package/templates/init/colyseus-cloud/package.json +15 -0
- package/templates/init/digitalocean-app.yaml +30 -0
- package/templates/init/docker-compose.full.yml +45 -0
- package/templates/init/docker-compose.websocket.yml +41 -0
- package/templates/init/fly.toml +56 -0
- package/templates/init/heroku.yml +11 -0
- package/templates/init/kubernetes/deployment.yaml +71 -0
- package/templates/init/kubernetes/redis.yaml +73 -0
- package/templates/init/kubernetes/service.yaml +24 -0
- package/templates/init/monitoring/grafana-dashboard.json +539 -0
- package/templates/init/monitoring/prometheus-alerts.yml +51 -0
- package/templates/init/monitoring/prometheus-scrape.yml +17 -0
- package/templates/init/railway.json +13 -0
- package/templates/init/render.yaml +19 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// Passkeys of the admin console (game-server-admin-console D31, DR §7.11), verified by hand with node:crypto: WebAuthn
|
|
2
|
+
// registration with attestation "none" only, and assertions signed with ES256, RS256 or EdDSA. A passkey is a second
|
|
3
|
+
// factor after a password, and a full sign-in when the authenticator verified the user (password-less).
|
|
4
|
+
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
|
|
5
|
+
import { AdminRequestError } from "./AdminApiRouter.js";
|
|
6
|
+
import { CHALLENGE_LIFETIME_MS } from "./AdminConstants.js";
|
|
7
|
+
import { randomToken, sha256Hex } from "./AdminCrypto.js";
|
|
8
|
+
import { decodeCbor, decodeCborPrefix } from "./CborDecoder.js";
|
|
9
|
+
/** HTTP statuses of the refusals. */
|
|
10
|
+
const HTTP_BAD_REQUEST = 400;
|
|
11
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
12
|
+
const HTTP_NOT_FOUND = 404;
|
|
13
|
+
const HTTP_CONFLICT = 409;
|
|
14
|
+
/** Random bytes of a challenge and of a passkey id. */
|
|
15
|
+
const CHALLENGE_BYTES = 32;
|
|
16
|
+
const PASSKEY_ID_BYTES = 12;
|
|
17
|
+
/** The relying party name authenticators show. */
|
|
18
|
+
const RELYING_PARTY_NAME = "Babylon Game Server";
|
|
19
|
+
/** The algorithms the console accepts, in order of preference (D31). */
|
|
20
|
+
const ACCEPTED_ALGORITHMS = [-7, -8, -257];
|
|
21
|
+
/** COSE algorithm ids. */
|
|
22
|
+
const COSE_ES256 = -7;
|
|
23
|
+
const COSE_EDDSA = -8;
|
|
24
|
+
const COSE_RS256 = -257;
|
|
25
|
+
/** COSE key labels and values (RFC 9053). */
|
|
26
|
+
const COSE_KEY_TYPE = 1;
|
|
27
|
+
const COSE_ALGORITHM = 3;
|
|
28
|
+
const COSE_EC2_CURVE = -1;
|
|
29
|
+
const COSE_EC2_X = -2;
|
|
30
|
+
const COSE_EC2_Y = -3;
|
|
31
|
+
const COSE_RSA_MODULUS = -1;
|
|
32
|
+
const COSE_RSA_EXPONENT = -2;
|
|
33
|
+
const COSE_OKP_CURVE = -1;
|
|
34
|
+
const COSE_OKP_X = -2;
|
|
35
|
+
const COSE_KEY_TYPE_OKP = 1;
|
|
36
|
+
const COSE_KEY_TYPE_EC2 = 2;
|
|
37
|
+
const COSE_KEY_TYPE_RSA = 3;
|
|
38
|
+
const COSE_CURVE_P256 = 1;
|
|
39
|
+
const COSE_CURVE_ED25519 = 6;
|
|
40
|
+
/** Authenticator data layout (WebAuthn §6.1). */
|
|
41
|
+
const RP_ID_HASH_LENGTH = 32;
|
|
42
|
+
const FLAGS_OFFSET = 32;
|
|
43
|
+
const SIGN_COUNT_OFFSET = 33;
|
|
44
|
+
const ATTESTED_DATA_OFFSET = 37;
|
|
45
|
+
const AAGUID_LENGTH = 16;
|
|
46
|
+
const CREDENTIAL_ID_LENGTH_BYTES = 2;
|
|
47
|
+
/** Authenticator data flags. */
|
|
48
|
+
const FLAG_USER_PRESENT = 0x01;
|
|
49
|
+
const FLAG_USER_VERIFIED = 0x04;
|
|
50
|
+
const FLAG_ATTESTED_DATA = 0x40;
|
|
51
|
+
/** clientDataJSON types. */
|
|
52
|
+
const CREATE_TYPE = "webauthn.create";
|
|
53
|
+
const GET_TYPE = "webauthn.get";
|
|
54
|
+
/** The id prefix of passkey challenges in pendingSignIns. */
|
|
55
|
+
const CHALLENGE_ID_PREFIX = "passkey:";
|
|
56
|
+
/** The answer of every refused assertion or registration. */
|
|
57
|
+
const REFUSED_MESSAGE = "the passkey was not accepted";
|
|
58
|
+
/** Registers passkeys and verifies passkey sign-ins. */
|
|
59
|
+
export class Passkeys {
|
|
60
|
+
/** The admin store. */
|
|
61
|
+
store;
|
|
62
|
+
/** The clock of challenge expiries and use times. */
|
|
63
|
+
clock;
|
|
64
|
+
/** The passkey settings. */
|
|
65
|
+
config;
|
|
66
|
+
/** The admin accounts. */
|
|
67
|
+
accounts;
|
|
68
|
+
/**
|
|
69
|
+
* Creates the passkey service.
|
|
70
|
+
* @param store The admin store.
|
|
71
|
+
* @param clock The clock.
|
|
72
|
+
* @param config admin.passkeys.
|
|
73
|
+
* @param accounts The admin accounts.
|
|
74
|
+
*/
|
|
75
|
+
constructor(store, clock, config, accounts) {
|
|
76
|
+
this.store = store;
|
|
77
|
+
this.clock = clock;
|
|
78
|
+
this.config = config;
|
|
79
|
+
this.accounts = accounts;
|
|
80
|
+
}
|
|
81
|
+
/** True when admin.passkeys.enabled. */
|
|
82
|
+
get isEnabled() {
|
|
83
|
+
return this.config.enabled;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Options of navigator.credentials.create for a signed-in admin.
|
|
87
|
+
* @param user The account.
|
|
88
|
+
* @param consoleOrigin The console origin (its host is the default relying party id).
|
|
89
|
+
* @returns The options.
|
|
90
|
+
*/
|
|
91
|
+
async creationOptions(user, consoleOrigin) {
|
|
92
|
+
const challenge = await this.createChallenge("passkeyRegistration", user.userId);
|
|
93
|
+
const existing = await this.passkeysOf(user.userId);
|
|
94
|
+
return {
|
|
95
|
+
challenge: challenge,
|
|
96
|
+
rp: { id: this.rpIdOf(consoleOrigin), name: RELYING_PARTY_NAME },
|
|
97
|
+
user: { id: user.userId, name: user.username, displayName: user.displayName },
|
|
98
|
+
pubKeyCredParams: ACCEPTED_ALGORITHMS.map((algorithm) => ({ type: "public-key", alg: algorithm })),
|
|
99
|
+
timeout: CHALLENGE_LIFETIME_MS,
|
|
100
|
+
attestation: "none",
|
|
101
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
|
102
|
+
excludeCredentials: existing.map((passkey) => ({ type: "public-key", id: passkey.credentialId }))
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Registers a passkey from navigator.credentials.create's result (attestation "none" only).
|
|
107
|
+
* @param user The signed-in account.
|
|
108
|
+
* @param name The passkey's name.
|
|
109
|
+
* @param attestation The credential.
|
|
110
|
+
* @param consoleOrigin The console origin.
|
|
111
|
+
* @returns The stored passkey.
|
|
112
|
+
* @throws AdminRequestError 400 for a refused credential, 409 for a passkey already registered.
|
|
113
|
+
*/
|
|
114
|
+
async register(user, name, attestation, consoleOrigin) {
|
|
115
|
+
const clientData = parseClientData(attestation.response.clientDataJSON);
|
|
116
|
+
if (clientData.type !== CREATE_TYPE || clientData.origin !== consoleOrigin)
|
|
117
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
118
|
+
const challenge = await this.consumeChallenge(clientData.challenge, "passkeyRegistration", HTTP_BAD_REQUEST);
|
|
119
|
+
if (challenge.userId !== user.userId)
|
|
120
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
121
|
+
const authenticatorData = parseAttestationObject(attestation.response.attestationObject);
|
|
122
|
+
this.checkAuthenticatorData(authenticatorData, consoleOrigin, HTTP_BAD_REQUEST);
|
|
123
|
+
if (authenticatorData.credential == null)
|
|
124
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
125
|
+
const credentialId = authenticatorData.credential.credentialId.toString("base64url");
|
|
126
|
+
if (credentialId !== attestation.rawId)
|
|
127
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
128
|
+
if ((await this.findByCredentialId(credentialId)) != null)
|
|
129
|
+
throw new AdminRequestError(HTTP_CONFLICT, "this passkey is already registered");
|
|
130
|
+
const publicKey = coseKeyToJwk(authenticatorData.credential.coseKey);
|
|
131
|
+
const record = {
|
|
132
|
+
passkeyId: randomToken(PASSKEY_ID_BYTES),
|
|
133
|
+
userId: user.userId,
|
|
134
|
+
name: (name.trim() !== "") ? name.trim() : "Passkey",
|
|
135
|
+
credentialId: credentialId,
|
|
136
|
+
publicKeyJwk: publicKey.jwk,
|
|
137
|
+
algorithm: publicKey.algorithm,
|
|
138
|
+
signCount: authenticatorData.signCount,
|
|
139
|
+
createdAt: new Date(this.clock.now()).toISOString(),
|
|
140
|
+
lastUsedAt: null
|
|
141
|
+
};
|
|
142
|
+
await this.store.putDocument("passkeys", record.passkeyId, record, 0);
|
|
143
|
+
return record;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Options of navigator.credentials.get. With a username, its passkeys are listed; an unknown username gets an empty
|
|
147
|
+
* list like a discoverable sign-in, so the answer never reveals which usernames exist.
|
|
148
|
+
* @param username The username, or null for any account's discoverable passkey.
|
|
149
|
+
* @param consoleOrigin The console origin (its host is the default relying party id).
|
|
150
|
+
* @returns The options.
|
|
151
|
+
*/
|
|
152
|
+
async requestOptions(username, consoleOrigin) {
|
|
153
|
+
const stored = (username != null && username !== "") ? await this.accounts.findByUsername(username) : null;
|
|
154
|
+
const passkeys = (stored != null) ? await this.passkeysOf(stored.value.userId) : [];
|
|
155
|
+
const challenge = await this.createChallenge("passkeyAssertion", stored?.value.userId ?? null);
|
|
156
|
+
return {
|
|
157
|
+
challenge: challenge,
|
|
158
|
+
rpId: this.rpIdOf(consoleOrigin),
|
|
159
|
+
timeout: CHALLENGE_LIFETIME_MS,
|
|
160
|
+
userVerification: "preferred",
|
|
161
|
+
allowCredentials: passkeys.map((passkey) => ({ type: "public-key", id: passkey.credentialId }))
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Verifies navigator.credentials.get's result (DR §7.11): client data, relying party, presence, signature and sign
|
|
166
|
+
* count, then records the use.
|
|
167
|
+
* @param assertion The assertion.
|
|
168
|
+
* @param consoleOrigin The console origin.
|
|
169
|
+
* @returns The account and whether the authenticator verified the user.
|
|
170
|
+
* @throws AdminRequestError 401 for a refused assertion.
|
|
171
|
+
*/
|
|
172
|
+
async verifyAssertion(assertion, consoleOrigin) {
|
|
173
|
+
const clientData = parseClientData(assertion.response.clientDataJSON);
|
|
174
|
+
if (clientData.type !== GET_TYPE || clientData.origin !== consoleOrigin)
|
|
175
|
+
throw refused(HTTP_UNAUTHORIZED);
|
|
176
|
+
const challenge = await this.consumeChallenge(clientData.challenge, "passkeyAssertion", HTTP_UNAUTHORIZED);
|
|
177
|
+
const stored = await this.findByCredentialId(assertion.rawId);
|
|
178
|
+
if (stored == null || (challenge.userId != null && challenge.userId !== stored.value.userId))
|
|
179
|
+
throw refused(HTTP_UNAUTHORIZED);
|
|
180
|
+
const authenticatorDataBytes = Buffer.from(assertion.response.authenticatorData, "base64url");
|
|
181
|
+
const authenticatorData = parseAuthenticatorData(authenticatorDataBytes, false);
|
|
182
|
+
this.checkAuthenticatorData(authenticatorData, consoleOrigin, HTTP_UNAUTHORIZED);
|
|
183
|
+
const signedBytes = Buffer.concat([authenticatorDataBytes, createHash("sha256").update(Buffer.from(assertion.response.clientDataJSON, "base64url")).digest()]);
|
|
184
|
+
if (!verifySignature(stored.value, signedBytes, Buffer.from(assertion.response.signature, "base64url")))
|
|
185
|
+
throw refused(HTTP_UNAUTHORIZED);
|
|
186
|
+
if (!isSignCountValid(stored.value.signCount, authenticatorData.signCount))
|
|
187
|
+
throw refused(HTTP_UNAUTHORIZED);
|
|
188
|
+
const used = Object.assign({}, stored.value, { signCount: authenticatorData.signCount, lastUsedAt: new Date(this.clock.now()).toISOString() });
|
|
189
|
+
await this.store.putDocument("passkeys", stored.id, used, stored.revision);
|
|
190
|
+
const user = await this.accounts.getUser(used.userId);
|
|
191
|
+
if (user == null)
|
|
192
|
+
throw refused(HTTP_UNAUTHORIZED);
|
|
193
|
+
return { user: user.value, userVerified: (authenticatorData.flags & FLAG_USER_VERIFIED) !== 0 };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* An account's passkeys, oldest first.
|
|
197
|
+
* @param userId The account.
|
|
198
|
+
* @returns The passkeys.
|
|
199
|
+
*/
|
|
200
|
+
async list(userId) {
|
|
201
|
+
const passkeys = await this.passkeysOf(userId);
|
|
202
|
+
return passkeys.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
|
203
|
+
.map((passkey) => ({ passkeyId: passkey.passkeyId, name: passkey.name, createdAt: passkey.createdAt, lastUsedAt: passkey.lastUsedAt }));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Removes one of an account's passkeys.
|
|
207
|
+
* @param userId The account.
|
|
208
|
+
* @param passkeyId The passkey.
|
|
209
|
+
* @throws AdminRequestError 404 when the account has no such passkey.
|
|
210
|
+
*/
|
|
211
|
+
async remove(userId, passkeyId) {
|
|
212
|
+
const stored = await this.store.getDocument("passkeys", passkeyId);
|
|
213
|
+
if (stored == null || stored.value.userId !== userId)
|
|
214
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such passkey");
|
|
215
|
+
await this.store.deleteDocument("passkeys", passkeyId);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The relying party id: admin.passkeys.rpId, or the console's host name.
|
|
219
|
+
* @param consoleOrigin The console origin.
|
|
220
|
+
* @returns The id.
|
|
221
|
+
*/
|
|
222
|
+
rpIdOf(consoleOrigin) {
|
|
223
|
+
return this.config.rpId ?? new URL(consoleOrigin).hostname;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Checks authenticator data against the relying party and the user-present flag. User verification is checked by
|
|
227
|
+
* the caller, since only a password-less sign-in needs it.
|
|
228
|
+
* @param authenticatorData The parsed data.
|
|
229
|
+
* @param consoleOrigin The console origin.
|
|
230
|
+
* @param status The status of a refusal.
|
|
231
|
+
* @throws AdminRequestError with the status on a mismatch.
|
|
232
|
+
*/
|
|
233
|
+
checkAuthenticatorData(authenticatorData, consoleOrigin, status) {
|
|
234
|
+
const expectedHash = createHash("sha256").update(this.rpIdOf(consoleOrigin)).digest();
|
|
235
|
+
if (!authenticatorData.rpIdHash.equals(expectedHash))
|
|
236
|
+
throw refused(status);
|
|
237
|
+
if ((authenticatorData.flags & FLAG_USER_PRESENT) === 0)
|
|
238
|
+
throw refused(status);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Creates and stores a challenge for CHALLENGE_LIFETIME_MS.
|
|
242
|
+
* @param kind Registration or sign-in.
|
|
243
|
+
* @param userId The account it is for, or null.
|
|
244
|
+
* @returns The challenge, base64url.
|
|
245
|
+
*/
|
|
246
|
+
async createChallenge(kind, userId) {
|
|
247
|
+
const challenge = randomBytes(CHALLENGE_BYTES).toString("base64url");
|
|
248
|
+
const record = { kind: kind, userId: userId, expiresAt: new Date(this.clock.now() + CHALLENGE_LIFETIME_MS).toISOString() };
|
|
249
|
+
await this.store.putDocument("pendingSignIns", CHALLENGE_ID_PREFIX + sha256Hex(challenge), record, 0);
|
|
250
|
+
return challenge;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Takes a live challenge of a kind out of the store (single use).
|
|
254
|
+
* @param challenge The challenge from clientDataJSON.
|
|
255
|
+
* @param kind The kind the call needs.
|
|
256
|
+
* @param status The status of a refusal.
|
|
257
|
+
* @returns The challenge record.
|
|
258
|
+
* @throws AdminRequestError with the status when it is unknown, expired, used or of another kind.
|
|
259
|
+
*/
|
|
260
|
+
async consumeChallenge(challenge, kind, status) {
|
|
261
|
+
const documentId = CHALLENGE_ID_PREFIX + sha256Hex(challenge);
|
|
262
|
+
const stored = await this.store.getDocument("pendingSignIns", documentId);
|
|
263
|
+
if (stored == null || !(await this.store.deleteDocument("pendingSignIns", documentId)))
|
|
264
|
+
throw refused(status);
|
|
265
|
+
if (stored.value.kind !== kind || Date.parse(stored.value.expiresAt) <= this.clock.now())
|
|
266
|
+
throw refused(status);
|
|
267
|
+
return stored.value;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* An account's stored passkeys.
|
|
271
|
+
* @param userId The account.
|
|
272
|
+
* @returns The records.
|
|
273
|
+
*/
|
|
274
|
+
async passkeysOf(userId) {
|
|
275
|
+
const documents = await this.store.listDocuments("passkeys");
|
|
276
|
+
return documents.map((document) => document.value).filter((passkey) => passkey.userId === userId);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The passkey with a credential id.
|
|
280
|
+
* @param credentialId The base64url credential id.
|
|
281
|
+
* @returns The stored passkey, or null.
|
|
282
|
+
*/
|
|
283
|
+
async findByCredentialId(credentialId) {
|
|
284
|
+
const documents = await this.store.listDocuments("passkeys");
|
|
285
|
+
return documents.find((document) => document.value.credentialId === credentialId) ?? null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The refusal of a passkey check (the same text for every reason, so a probe learns nothing).
|
|
290
|
+
* @param status 400 for registration, 401 for sign-in.
|
|
291
|
+
* @returns The error.
|
|
292
|
+
*/
|
|
293
|
+
function refused(status) {
|
|
294
|
+
return new AdminRequestError(status, REFUSED_MESSAGE);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Parses base64url clientDataJSON.
|
|
298
|
+
* @param clientDataJson The base64url text.
|
|
299
|
+
* @returns Its type, challenge and origin.
|
|
300
|
+
* @throws AdminRequestError 400 when it is not the JSON WebAuthn defines.
|
|
301
|
+
*/
|
|
302
|
+
function parseClientData(clientDataJson) {
|
|
303
|
+
try {
|
|
304
|
+
const parsed = JSON.parse(Buffer.from(clientDataJson, "base64url").toString("utf8"));
|
|
305
|
+
if (typeof parsed.type === "string" && typeof parsed.challenge === "string" && typeof parsed.origin === "string") {
|
|
306
|
+
return { type: parsed.type, challenge: parsed.challenge, origin: parsed.origin };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Falls through to the refusal below.
|
|
311
|
+
}
|
|
312
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Decodes an attestation object and checks its format is "none" (D31).
|
|
316
|
+
* @param attestationObject The base64url CBOR attestation object.
|
|
317
|
+
* @returns Its parsed authenticator data with the attested credential.
|
|
318
|
+
* @throws AdminRequestError 400 for another format or malformed data.
|
|
319
|
+
*/
|
|
320
|
+
function parseAttestationObject(attestationObject) {
|
|
321
|
+
let decoded;
|
|
322
|
+
try {
|
|
323
|
+
decoded = decodeCbor(Buffer.from(attestationObject, "base64url"));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
327
|
+
}
|
|
328
|
+
if (!(decoded instanceof Map) || decoded.get("fmt") !== "none")
|
|
329
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "only passkeys without attestation (\"none\") are accepted");
|
|
330
|
+
const authenticatorData = decoded.get("authData");
|
|
331
|
+
if (!(authenticatorData instanceof Uint8Array))
|
|
332
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
333
|
+
return parseAuthenticatorData(Buffer.from(authenticatorData), true);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Parses authenticator data (WebAuthn §6.1).
|
|
337
|
+
* @param bytes The data.
|
|
338
|
+
* @param expectsCredential True for registration: the attested credential data must follow.
|
|
339
|
+
* @returns The parsed data.
|
|
340
|
+
* @throws AdminRequestError 400 when it is too short or the credential is malformed.
|
|
341
|
+
*/
|
|
342
|
+
function parseAuthenticatorData(bytes, expectsCredential) {
|
|
343
|
+
if (bytes.length < ATTESTED_DATA_OFFSET)
|
|
344
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
345
|
+
const flags = bytes[FLAGS_OFFSET];
|
|
346
|
+
const parsed = { rpIdHash: bytes.subarray(0, RP_ID_HASH_LENGTH), flags: flags, signCount: bytes.readUInt32BE(SIGN_COUNT_OFFSET), credential: null };
|
|
347
|
+
if (!expectsCredential)
|
|
348
|
+
return parsed;
|
|
349
|
+
if ((flags & FLAG_ATTESTED_DATA) === 0)
|
|
350
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
351
|
+
const lengthOffset = ATTESTED_DATA_OFFSET + AAGUID_LENGTH;
|
|
352
|
+
if (bytes.length < lengthOffset + CREDENTIAL_ID_LENGTH_BYTES)
|
|
353
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
354
|
+
const credentialIdLength = bytes.readUInt16BE(lengthOffset);
|
|
355
|
+
const credentialIdStart = lengthOffset + CREDENTIAL_ID_LENGTH_BYTES;
|
|
356
|
+
const keyStart = credentialIdStart + credentialIdLength;
|
|
357
|
+
if (bytes.length <= keyStart)
|
|
358
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
359
|
+
let coseKey;
|
|
360
|
+
try {
|
|
361
|
+
coseKey = decodeCborPrefix(bytes.subarray(keyStart)).value;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
365
|
+
}
|
|
366
|
+
if (!(coseKey instanceof Map))
|
|
367
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
368
|
+
parsed.credential = { credentialId: bytes.subarray(credentialIdStart, keyStart), coseKey: coseKey };
|
|
369
|
+
return parsed;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Turns a COSE public key into a JWK: EC2 P-256 (x -2, y -3), RSA (n -1, e -2) or OKP Ed25519 (x -2).
|
|
373
|
+
* @param coseKey The decoded COSE key.
|
|
374
|
+
* @returns The JWK and its algorithm.
|
|
375
|
+
* @throws AdminRequestError 400 for another key type, curve or algorithm.
|
|
376
|
+
*/
|
|
377
|
+
export function coseKeyToJwk(coseKey) {
|
|
378
|
+
const keyType = coseKey.get(COSE_KEY_TYPE);
|
|
379
|
+
const algorithm = coseKey.get(COSE_ALGORITHM);
|
|
380
|
+
if (keyType === COSE_KEY_TYPE_EC2 && algorithm === COSE_ES256 && coseKey.get(COSE_EC2_CURVE) === COSE_CURVE_P256) {
|
|
381
|
+
return { jwk: { kty: "EC", crv: "P-256", x: base64UrlOf(coseKey.get(COSE_EC2_X)), y: base64UrlOf(coseKey.get(COSE_EC2_Y)) }, algorithm: COSE_ES256 };
|
|
382
|
+
}
|
|
383
|
+
if (keyType === COSE_KEY_TYPE_RSA && algorithm === COSE_RS256) {
|
|
384
|
+
return { jwk: { kty: "RSA", n: base64UrlOf(coseKey.get(COSE_RSA_MODULUS)), e: base64UrlOf(coseKey.get(COSE_RSA_EXPONENT)) }, algorithm: COSE_RS256 };
|
|
385
|
+
}
|
|
386
|
+
if (keyType === COSE_KEY_TYPE_OKP && algorithm === COSE_EDDSA && coseKey.get(COSE_OKP_CURVE) === COSE_CURVE_ED25519) {
|
|
387
|
+
return { jwk: { kty: "OKP", crv: "Ed25519", x: base64UrlOf(coseKey.get(COSE_OKP_X)) }, algorithm: COSE_EDDSA };
|
|
388
|
+
}
|
|
389
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "the passkey uses an algorithm the console does not accept (ES256, RS256 or EdDSA)");
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* A COSE byte string as base64url.
|
|
393
|
+
* @param value The decoded value.
|
|
394
|
+
* @returns The base64url text.
|
|
395
|
+
* @throws AdminRequestError 400 when it is not a byte string.
|
|
396
|
+
*/
|
|
397
|
+
function base64UrlOf(value) {
|
|
398
|
+
if (!(value instanceof Uint8Array))
|
|
399
|
+
throw refused(HTTP_BAD_REQUEST);
|
|
400
|
+
return Buffer.from(value).toString("base64url");
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Verifies an assertion signature with the stored key (ES256 signatures are DER-encoded).
|
|
404
|
+
* @param passkey The stored passkey.
|
|
405
|
+
* @param signedBytes authenticatorData || sha256(clientDataJSON).
|
|
406
|
+
* @param signature The signature.
|
|
407
|
+
* @returns True when it verifies.
|
|
408
|
+
*/
|
|
409
|
+
function verifySignature(passkey, signedBytes, signature) {
|
|
410
|
+
try {
|
|
411
|
+
const key = createPublicKey({ key: passkey.publicKeyJwk, format: "jwk" });
|
|
412
|
+
if (passkey.algorithm === COSE_EDDSA)
|
|
413
|
+
return verify(null, signedBytes, key, signature);
|
|
414
|
+
if (passkey.algorithm === COSE_ES256)
|
|
415
|
+
return verify("sha256", signedBytes, { key: key, dsaEncoding: "der" }, signature);
|
|
416
|
+
return verify("sha256", signedBytes, key, signature);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The sign count rule (D31): it must grow, unless both counts are 0 (an authenticator without a counter).
|
|
424
|
+
* @param storedCount The last count seen.
|
|
425
|
+
* @param newCount The assertion's count.
|
|
426
|
+
* @returns True when it is acceptable.
|
|
427
|
+
*/
|
|
428
|
+
function isSignCountValid(storedCount, newCount) {
|
|
429
|
+
if (storedCount === 0 && newCount === 0)
|
|
430
|
+
return true;
|
|
431
|
+
return newCount > storedCount;
|
|
432
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AdminApiRouter } from "./AdminApiRouter.js";
|
|
2
|
+
import type { BanRecord } from "./AdminRecords.js";
|
|
3
|
+
import type { AdminServices } from "./AdminServices.js";
|
|
4
|
+
/**
|
|
5
|
+
* Registers the players/*, lobby/kick and bans/* routes.
|
|
6
|
+
* @param router The admin API router.
|
|
7
|
+
* @param services The shared services.
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerPlayerRoutes(router: AdminApiRouter, services: AdminServices): void;
|
|
10
|
+
/**
|
|
11
|
+
* Registers this instance's searchPlayers, disconnectBanned and kickFromLobby fleet commands (R15).
|
|
12
|
+
* @param services The shared services.
|
|
13
|
+
*/
|
|
14
|
+
export declare function registerPlayerCommands(services: AdminServices): void;
|
|
15
|
+
/**
|
|
16
|
+
* Reads every stored ban into this instance's BanList (at start and on every "bans" change announcement, D15).
|
|
17
|
+
* @param services The shared services.
|
|
18
|
+
* @returns Every stored ban.
|
|
19
|
+
*/
|
|
20
|
+
export declare function reloadBanList(services: AdminServices): Promise<BanRecord[]>;
|