@openmdm/core 0.8.0 → 0.9.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/index.d.ts +180 -3
- package/dist/index.js +233 -5
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +150 -2
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/dashboard.ts +40 -0
- package/src/device-identity.ts +338 -0
- package/src/index.ts +165 -5
- package/src/types.ts +155 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { WebhookConfig, Logger, MDMEvent, WebhookEndpoint, EventType, DatabaseAdapter, TenantManager, AuthorizationManager, AuditConfig, AuditManager, ScheduleManager, MessageQueueManager, DashboardManager, PluginStorageAdapter, MDMConfig,
|
|
2
|
-
export { AppInstallationSummary, AppRollback, AppVersion, Application, ApplicationManager, ApplicationNotFoundError, AuditAction, AuditLog, AuditLogFilter, AuditLogListResult, AuditSummary, AuthConfig, AuthenticationError, AuthorizationError, Command, CommandFilter, CommandManager, CommandNotFoundError, CommandResult, CommandStatus, CommandSuccessRates, CommandType, CreateAppRollbackInput, CreateApplicationInput, CreateAuditLogInput, CreateDeviceInput, CreateGroupInput, CreatePolicyInput, CreateRoleInput, CreateScheduledTaskInput, CreateTenantInput, CreateUserInput, DashboardStats, DeployTarget, Device, DeviceFilter, DeviceListResult, DeviceLocation, DeviceManager, DeviceNotFoundError, DeviceStatus, DeviceStatusBreakdown, EnqueueMessageInput, EnrollmentConfig, EnrollmentError, EnrollmentMethod, EnrollmentResponse, EnrollmentTrendPoint, EventFilter, EventHandler, EventPayloadMap, Group, GroupHierarchyStats, GroupManager, GroupNotFoundError, GroupTreeNode, HardwareControl, Heartbeat, InstalledApp, LogContext, MDMError, MDMPlugin, MaintenanceWindow, PasswordPolicy, Permission, PermissionAction, PermissionResource, PluginMiddleware, PluginRoute, PluginStorageEntry, Policy, PolicyApplication, PolicyManager, PolicyNotFoundError, PolicySettings, PushAdapter, PushBatchResult, PushConfig, PushMessage, PushProviderConfig, PushResult, PushToken, QueueMessageStatus, QueueStats, QueuedMessage, RegisterPushTokenInput, Role, RoleNotFoundError, ScheduledTask, ScheduledTaskFilter, ScheduledTaskListResult, ScheduledTaskStatus, SendCommandInput, StorageConfig, SystemUpdatePolicy, TaskExecution, TaskSchedule, TaskType, Tenant, TenantFilter, TenantListResult, TenantNotFoundError, TenantSettings, TenantStats, TenantStatus, TimeWindow, UpdateApplicationInput, UpdateDeviceInput, UpdateGroupInput, UpdatePolicyInput, UpdateRoleInput, UpdateScheduledTaskInput, UpdateTenantInput, UpdateUserInput, User, UserFilter, UserListResult, UserNotFoundError, UserWithRoles, ValidationError, VpnConfig, WebhookDeliveryResult, WebhookManager, WifiConfig } from './types.js';
|
|
1
|
+
import { WebhookConfig, Logger, MDMEvent, WebhookEndpoint, EventType, MDMInstance, DeviceIdentityVerification, DatabaseAdapter, TenantManager, AuthorizationManager, AuditConfig, AuditManager, ScheduleManager, MessageQueueManager, DashboardManager, PluginStorageAdapter, MDMConfig, EnrollmentRequest } from './types.js';
|
|
2
|
+
export { AppInstallationSummary, AppRollback, AppVersion, Application, ApplicationManager, ApplicationNotFoundError, AuditAction, AuditLog, AuditLogFilter, AuditLogListResult, AuditSummary, AuthConfig, AuthenticationError, AuthorizationError, Command, CommandFilter, CommandManager, CommandNotFoundError, CommandResult, CommandStatus, CommandSuccessRates, CommandType, CreateAppRollbackInput, CreateApplicationInput, CreateAuditLogInput, CreateDeviceInput, CreateGroupInput, CreatePolicyInput, CreateRoleInput, CreateScheduledTaskInput, CreateTenantInput, CreateUserInput, DashboardStats, DeployTarget, Device, DeviceFilter, DeviceListResult, DeviceLocation, DeviceManager, DeviceNotFoundError, DeviceStatus, DeviceStatusBreakdown, EnqueueMessageInput, EnrollmentChallenge, EnrollmentConfig, EnrollmentError, EnrollmentMethod, EnrollmentResponse, EnrollmentTrendPoint, EventFilter, EventHandler, EventPayloadMap, Group, GroupHierarchyStats, GroupManager, GroupNotFoundError, GroupTreeNode, HardwareControl, Heartbeat, InstalledApp, LogContext, MDMError, MDMPlugin, MaintenanceWindow, PasswordPolicy, Permission, PermissionAction, PermissionResource, PinnedKeyConfig, PluginMiddleware, PluginRoute, PluginStorageEntry, Policy, PolicyApplication, PolicyManager, PolicyNotFoundError, PolicySettings, PushAdapter, PushBatchResult, PushConfig, PushMessage, PushProviderConfig, PushResult, PushToken, QueueMessageStatus, QueueStats, QueuedMessage, RegisterPushTokenInput, Role, RoleNotFoundError, ScheduledTask, ScheduledTaskFilter, ScheduledTaskListResult, ScheduledTaskStatus, SendCommandInput, StorageConfig, SystemUpdatePolicy, TaskExecution, TaskSchedule, TaskType, Tenant, TenantFilter, TenantListResult, TenantNotFoundError, TenantSettings, TenantStats, TenantStatus, TimeWindow, UpdateApplicationInput, UpdateDeviceInput, UpdateGroupInput, UpdatePolicyInput, UpdateRoleInput, UpdateScheduledTaskInput, UpdateTenantInput, UpdateUserInput, User, UserFilter, UserListResult, UserNotFoundError, UserWithRoles, ValidationError, VpnConfig, WebhookDeliveryResult, WebhookManager, WifiConfig } from './types.js';
|
|
3
3
|
export { ColumnDefinition, ColumnType, IndexDefinition, SchemaDefinition, TableDefinition, camelToSnake, getColumnNames, getPrimaryKey, getTableNames, mdmSchema, snakeToCamel, transformToCamelCase, transformToSnakeCase } from './schema.js';
|
|
4
|
+
import { KeyObject } from 'crypto';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* OpenMDM Agent Wire Protocol v2.
|
|
@@ -195,6 +196,182 @@ declare function createConsoleLogger(scope?: string[]): Logger;
|
|
|
195
196
|
*/
|
|
196
197
|
declare function createSilentLogger(): Logger;
|
|
197
198
|
|
|
199
|
+
/**
|
|
200
|
+
* OpenMDM Device Identity
|
|
201
|
+
*
|
|
202
|
+
* Device-pinned asymmetric identity, using an ECDSA P-256 keypair the
|
|
203
|
+
* device generates in its own Keystore and registers with the server on
|
|
204
|
+
* first enrollment. After pinning, every consumer can verify a signed
|
|
205
|
+
* request against the same pinned public key — no shared HMAC secret,
|
|
206
|
+
* no APK extraction footgun, no dependence on Google hardware
|
|
207
|
+
* attestation (which most non-GMS fleet hardware cannot produce).
|
|
208
|
+
*
|
|
209
|
+
* This module is the reusable primitive. `@openmdm/core` uses it to
|
|
210
|
+
* gate `/agent/enroll` and will use it for `/agent/*` in Phase 2c.
|
|
211
|
+
* External consumers (midiamob's `deviceValidation.ts`, other custom
|
|
212
|
+
* servers) import the same functions to verify requests against the
|
|
213
|
+
* same pinned key — one device identity, many consumers.
|
|
214
|
+
*
|
|
215
|
+
* Why zero dependencies: Node's built-in `node:crypto` supports EC
|
|
216
|
+
* P-256 SPKI import and `crypto.verify('sha256', ...)` over DER-encoded
|
|
217
|
+
* signatures, which is the default format the Android Keystore
|
|
218
|
+
* produces. We deliberately do not pull in `@peculiar/*` or `node-forge`
|
|
219
|
+
* for this primitive — the surface area we need is small enough that
|
|
220
|
+
* the built-in is the right call.
|
|
221
|
+
*
|
|
222
|
+
* @see docs/concepts/enrollment for the full flow
|
|
223
|
+
* @see docs/proposals/phase-2b-rollout for the Android + rollout story
|
|
224
|
+
*/
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Import an EC P-256 public key from base64-encoded SubjectPublicKeyInfo
|
|
228
|
+
* (SPKI) bytes — the standard on-wire format the Android Keystore
|
|
229
|
+
* produces when you call `certificate.publicKey.encoded` on a
|
|
230
|
+
* `KeyStore.getCertificate(alias)` result.
|
|
231
|
+
*
|
|
232
|
+
* Throws `InvalidPublicKeyError` on any parse failure. This is a
|
|
233
|
+
* security boundary — we do NOT return `null` on malformed input,
|
|
234
|
+
* because a caller that forgot to handle the null case would silently
|
|
235
|
+
* treat bad keys as "no key configured" and fall through to an
|
|
236
|
+
* insecure path.
|
|
237
|
+
*/
|
|
238
|
+
declare function importPublicKeyFromSpki(spkiBase64: string): KeyObject;
|
|
239
|
+
/**
|
|
240
|
+
* Verify an ECDSA-P256 signature over a message using a previously-
|
|
241
|
+
* imported or raw SPKI public key.
|
|
242
|
+
*
|
|
243
|
+
* Signature must be DER-encoded — the default Android Keystore
|
|
244
|
+
* produces DER, and `Signature.sign()` on JVM/Kotlin returns DER, so
|
|
245
|
+
* this matches what every reasonable agent sends on the wire.
|
|
246
|
+
*
|
|
247
|
+
* Returns `true` iff the signature is valid. Never throws on a bad
|
|
248
|
+
* signature (that is the whole point of a verify call). Throws only
|
|
249
|
+
* on an invalid public-key encoding, because that indicates a caller
|
|
250
|
+
* bug rather than a forged request.
|
|
251
|
+
*/
|
|
252
|
+
declare function verifyEcdsaSignature(publicKey: KeyObject | string, message: string, signatureBase64: string): boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Build the canonical message that an enrollment signature covers.
|
|
255
|
+
*
|
|
256
|
+
* Staying in lockstep with `@openmdm/client` and with the Android
|
|
257
|
+
* agent is load-bearing — any change here is a wire break across
|
|
258
|
+
* every enrolled device. The contract test in
|
|
259
|
+
* `packages/core/tests/device-identity.test.ts` guards against drift.
|
|
260
|
+
*
|
|
261
|
+
* Shape (order matters):
|
|
262
|
+
*
|
|
263
|
+
* publicKey |
|
|
264
|
+
* model | manufacturer | osVersion |
|
|
265
|
+
* serialNumber | imei | macAddress | androidId |
|
|
266
|
+
* method | timestamp | challenge
|
|
267
|
+
*
|
|
268
|
+
* The public key is prepended (rather than appended) because it's the
|
|
269
|
+
* field most likely to be the whole point of the message — putting it
|
|
270
|
+
* first makes the signature's intent visible at a glance in logs.
|
|
271
|
+
*/
|
|
272
|
+
declare function canonicalEnrollmentMessage(parts: {
|
|
273
|
+
publicKey: string;
|
|
274
|
+
model: string;
|
|
275
|
+
manufacturer: string;
|
|
276
|
+
osVersion: string;
|
|
277
|
+
serialNumber?: string;
|
|
278
|
+
imei?: string;
|
|
279
|
+
macAddress?: string;
|
|
280
|
+
androidId?: string;
|
|
281
|
+
method: string;
|
|
282
|
+
timestamp: string;
|
|
283
|
+
challenge: string;
|
|
284
|
+
}): string;
|
|
285
|
+
/**
|
|
286
|
+
* Build the canonical message that a *post-enrollment* request
|
|
287
|
+
* signature covers. Consumers (openmdm's `/agent/*` routes,
|
|
288
|
+
* midiamob's `deviceValidation.ts`, any custom server) call this
|
|
289
|
+
* with the fields they want committed to the signature.
|
|
290
|
+
*
|
|
291
|
+
* The shape is deliberately narrower than the enrollment form — only
|
|
292
|
+
* the parts every request has in common.
|
|
293
|
+
*
|
|
294
|
+
* deviceId | timestamp | body | nonce
|
|
295
|
+
*
|
|
296
|
+
* `nonce` is optional; pass an empty string when the request does not
|
|
297
|
+
* carry a challenge. Replay protection on non-enrollment traffic is
|
|
298
|
+
* the caller's job — if your server already has a timestamp window
|
|
299
|
+
* check, you don't need a nonce per request.
|
|
300
|
+
*/
|
|
301
|
+
declare function canonicalDeviceRequestMessage(parts: {
|
|
302
|
+
deviceId: string;
|
|
303
|
+
timestamp: string;
|
|
304
|
+
body: string;
|
|
305
|
+
nonce?: string;
|
|
306
|
+
}): string;
|
|
307
|
+
/**
|
|
308
|
+
* Verify a signed request from an enrolled device against the
|
|
309
|
+
* public key pinned on that device's row.
|
|
310
|
+
*
|
|
311
|
+
* This is the primitive every consumer of device-pinned-key identity
|
|
312
|
+
* calls. It performs exactly the checks required to know the request
|
|
313
|
+
* came from the device that originally enrolled, in constant-ish
|
|
314
|
+
* time:
|
|
315
|
+
*
|
|
316
|
+
* 1. Look up the device by id.
|
|
317
|
+
* 2. Confirm the device has a pinned public key (refusing silently
|
|
318
|
+
* if not — a device without a pinned key is still on the legacy
|
|
319
|
+
* HMAC path and cannot be verified here).
|
|
320
|
+
* 3. Verify the ECDSA signature over the provided canonical message.
|
|
321
|
+
*
|
|
322
|
+
* Returns a tagged union so callers can react to the specific failure
|
|
323
|
+
* mode:
|
|
324
|
+
*
|
|
325
|
+
* - `not-found` — the device id doesn't exist. Almost always a bug
|
|
326
|
+
* in the caller, or a stolen/revoked device id.
|
|
327
|
+
* Return 401 to the client.
|
|
328
|
+
* - `no-pinned-key` — the device is still on the HMAC path. Callers
|
|
329
|
+
* should fall through to their legacy verifier
|
|
330
|
+
* (or fail, if the caller has already migrated).
|
|
331
|
+
* - `signature-invalid` — the signature did not verify against the
|
|
332
|
+
* pinned key. Return 401. **Do NOT** re-pin the
|
|
333
|
+
* submitted public key in response to a failure
|
|
334
|
+
* here — that's how re-pinning becomes a hijack.
|
|
335
|
+
*/
|
|
336
|
+
declare function verifyDeviceRequest(opts: {
|
|
337
|
+
mdm: MDMInstance;
|
|
338
|
+
deviceId: string;
|
|
339
|
+
canonicalMessage: string;
|
|
340
|
+
signatureBase64: string;
|
|
341
|
+
}): Promise<DeviceIdentityVerification>;
|
|
342
|
+
/**
|
|
343
|
+
* Thrown when a submitted public key cannot be parsed. This is a
|
|
344
|
+
* caller-facing error — the device sent something that is not a
|
|
345
|
+
* well-formed SPKI EC P-256 public key.
|
|
346
|
+
*/
|
|
347
|
+
declare class InvalidPublicKeyError extends Error {
|
|
348
|
+
readonly cause?: Error | undefined;
|
|
349
|
+
readonly code = "INVALID_PUBLIC_KEY";
|
|
350
|
+
constructor(message: string, cause?: Error | undefined);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Thrown when a device attempts to re-enroll with a public key that
|
|
354
|
+
* does not match the one originally pinned for its enrollment id.
|
|
355
|
+
*
|
|
356
|
+
* This is the core "device identity continuity" check. The server
|
|
357
|
+
* will NEVER automatically re-pin on mismatch — rebinding a device
|
|
358
|
+
* identity requires an explicit admin action (future work).
|
|
359
|
+
*/
|
|
360
|
+
declare class PublicKeyMismatchError extends Error {
|
|
361
|
+
readonly deviceId: string;
|
|
362
|
+
readonly code = "PUBLIC_KEY_MISMATCH";
|
|
363
|
+
constructor(deviceId: string);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Thrown when an enrollment attempts to use a challenge that is
|
|
367
|
+
* missing, expired, or already consumed.
|
|
368
|
+
*/
|
|
369
|
+
declare class ChallengeInvalidError extends Error {
|
|
370
|
+
readonly challenge?: string | undefined;
|
|
371
|
+
readonly code = "CHALLENGE_INVALID";
|
|
372
|
+
constructor(message: string, challenge?: string | undefined);
|
|
373
|
+
}
|
|
374
|
+
|
|
198
375
|
/**
|
|
199
376
|
* OpenMDM Tenant Manager
|
|
200
377
|
*
|
|
@@ -327,4 +504,4 @@ declare function parsePluginKey(key: string): {
|
|
|
327
504
|
declare function createMDM(config: MDMConfig): MDMInstance;
|
|
328
505
|
declare function verifyEnrollmentSignature(request: EnrollmentRequest, secret: string): boolean;
|
|
329
506
|
|
|
330
|
-
export { AGENT_PROTOCOL_HEADER, AGENT_PROTOCOL_V2, type AgentAction, type AgentResponse, AuditConfig, AuditManager, AuthorizationManager, DashboardManager, DatabaseAdapter, EnrollmentRequest, EventType, Logger, MDMConfig, MDMEvent, MDMInstance, MessageQueueManager, PluginStorageAdapter, ScheduleManager, TenantManager, WebhookConfig, WebhookEndpoint, type WebhookPayload, agentFail, agentOk, createAuditManager, createAuthorizationManager, createConsoleLogger, createDashboardManager, createMDM, createMemoryPluginStorageAdapter, createMessageQueueManager, createPluginKey, createPluginStorageAdapter, createScheduleManager, createSilentLogger, createTenantManager, createWebhookManager, parsePluginKey, verifyEnrollmentSignature, verifyWebhookSignature, wantsAgentProtocolV2 };
|
|
507
|
+
export { AGENT_PROTOCOL_HEADER, AGENT_PROTOCOL_V2, type AgentAction, type AgentResponse, AuditConfig, AuditManager, AuthorizationManager, ChallengeInvalidError, DashboardManager, DatabaseAdapter, DeviceIdentityVerification, EnrollmentRequest, EventType, InvalidPublicKeyError, Logger, MDMConfig, MDMEvent, MDMInstance, MessageQueueManager, PluginStorageAdapter, PublicKeyMismatchError, ScheduleManager, TenantManager, WebhookConfig, WebhookEndpoint, type WebhookPayload, agentFail, agentOk, canonicalDeviceRequestMessage, canonicalEnrollmentMessage, createAuditManager, createAuthorizationManager, createConsoleLogger, createDashboardManager, createMDM, createMemoryPluginStorageAdapter, createMessageQueueManager, createPluginKey, createPluginStorageAdapter, createScheduleManager, createSilentLogger, createTenantManager, createWebhookManager, importPublicKeyFromSpki, parsePluginKey, verifyDeviceRequest, verifyEcdsaSignature, verifyEnrollmentSignature, verifyWebhookSignature, wantsAgentProtocolV2 };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID, createHmac, timingSafeEqual } from 'crypto';
|
|
1
|
+
import { randomUUID, createHmac, createPublicKey, verify, timingSafeEqual } from 'crypto';
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
|
|
@@ -1093,12 +1093,20 @@ function createMessageQueueManager(db) {
|
|
|
1093
1093
|
}
|
|
1094
1094
|
|
|
1095
1095
|
// src/dashboard.ts
|
|
1096
|
+
function assertNoTenantScopeRequested(tenantId, method) {
|
|
1097
|
+
if (tenantId) {
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
`DashboardManager.${method} was called with a tenantId but the database adapter does not implement tenant-scoped dashboard queries. Implement the matching DatabaseAdapter method, or omit tenantId to accept global stats. See docs/proposals/tenant-rbac-audit.md for context.`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1096
1103
|
function createDashboardManager(db) {
|
|
1097
1104
|
return {
|
|
1098
1105
|
async getStats(_tenantId) {
|
|
1099
1106
|
if (db.getDashboardStats) {
|
|
1100
1107
|
return db.getDashboardStats(_tenantId);
|
|
1101
1108
|
}
|
|
1109
|
+
assertNoTenantScopeRequested(_tenantId, "getStats");
|
|
1102
1110
|
const devices = await db.listDevices({
|
|
1103
1111
|
limit: 1e4
|
|
1104
1112
|
// Get all for counting
|
|
@@ -1156,6 +1164,7 @@ function createDashboardManager(db) {
|
|
|
1156
1164
|
if (db.getDeviceStatusBreakdown) {
|
|
1157
1165
|
return db.getDeviceStatusBreakdown(_tenantId);
|
|
1158
1166
|
}
|
|
1167
|
+
assertNoTenantScopeRequested(_tenantId, "getDeviceStatusBreakdown");
|
|
1159
1168
|
const devices = await db.listDevices({
|
|
1160
1169
|
limit: 1e4
|
|
1161
1170
|
});
|
|
@@ -1188,6 +1197,7 @@ function createDashboardManager(db) {
|
|
|
1188
1197
|
if (db.getEnrollmentTrend) {
|
|
1189
1198
|
return db.getEnrollmentTrend(days, _tenantId);
|
|
1190
1199
|
}
|
|
1200
|
+
assertNoTenantScopeRequested(_tenantId, "getEnrollmentTrend");
|
|
1191
1201
|
const now = /* @__PURE__ */ new Date();
|
|
1192
1202
|
const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1e3);
|
|
1193
1203
|
const events = await db.listEvents({
|
|
@@ -1244,6 +1254,7 @@ function createDashboardManager(db) {
|
|
|
1244
1254
|
if (db.getCommandSuccessRates) {
|
|
1245
1255
|
return db.getCommandSuccessRates(_tenantId);
|
|
1246
1256
|
}
|
|
1257
|
+
assertNoTenantScopeRequested(_tenantId, "getCommandSuccessRates");
|
|
1247
1258
|
const now = /* @__PURE__ */ new Date();
|
|
1248
1259
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1e3);
|
|
1249
1260
|
const commands = await db.listCommands({ limit: 1e4 });
|
|
@@ -1292,6 +1303,7 @@ function createDashboardManager(db) {
|
|
|
1292
1303
|
if (db.getAppInstallationSummary) {
|
|
1293
1304
|
return db.getAppInstallationSummary(_tenantId);
|
|
1294
1305
|
}
|
|
1306
|
+
assertNoTenantScopeRequested(_tenantId, "getAppInstallationSummary");
|
|
1295
1307
|
const apps = await db.listApplications();
|
|
1296
1308
|
const appMap = new Map(apps.map((a) => [a.packageName, a]));
|
|
1297
1309
|
const byStatus = {
|
|
@@ -1407,6 +1419,135 @@ function parsePluginKey(key) {
|
|
|
1407
1419
|
const [namespace, ...parts] = key.split(":");
|
|
1408
1420
|
return { namespace, parts };
|
|
1409
1421
|
}
|
|
1422
|
+
function importPublicKeyFromSpki(spkiBase64) {
|
|
1423
|
+
let buffer;
|
|
1424
|
+
try {
|
|
1425
|
+
buffer = Buffer.from(spkiBase64, "base64");
|
|
1426
|
+
} catch (err) {
|
|
1427
|
+
throw new InvalidPublicKeyError(
|
|
1428
|
+
"Public key is not valid base64",
|
|
1429
|
+
err instanceof Error ? err : void 0
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
if (buffer.length === 0) {
|
|
1433
|
+
throw new InvalidPublicKeyError("Public key is empty");
|
|
1434
|
+
}
|
|
1435
|
+
try {
|
|
1436
|
+
const key = createPublicKey({
|
|
1437
|
+
key: buffer,
|
|
1438
|
+
format: "der",
|
|
1439
|
+
type: "spki"
|
|
1440
|
+
});
|
|
1441
|
+
const asymmetricKeyType = key.asymmetricKeyType;
|
|
1442
|
+
if (asymmetricKeyType !== "ec") {
|
|
1443
|
+
throw new InvalidPublicKeyError(
|
|
1444
|
+
`Expected EC key, got ${asymmetricKeyType ?? "unknown"}`
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
const curve = key.asymmetricKeyDetails?.namedCurve;
|
|
1448
|
+
if (curve && curve !== "prime256v1" && curve !== "P-256") {
|
|
1449
|
+
throw new InvalidPublicKeyError(
|
|
1450
|
+
`Unsupported EC curve: ${curve}. Only P-256 is accepted.`
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
return key;
|
|
1454
|
+
} catch (err) {
|
|
1455
|
+
if (err instanceof InvalidPublicKeyError) throw err;
|
|
1456
|
+
throw new InvalidPublicKeyError(
|
|
1457
|
+
"Failed to parse SPKI public key",
|
|
1458
|
+
err instanceof Error ? err : void 0
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
function verifyEcdsaSignature(publicKey, message, signatureBase64) {
|
|
1463
|
+
const key = typeof publicKey === "string" ? importPublicKeyFromSpki(publicKey) : publicKey;
|
|
1464
|
+
let signatureBuffer;
|
|
1465
|
+
try {
|
|
1466
|
+
signatureBuffer = Buffer.from(signatureBase64, "base64");
|
|
1467
|
+
} catch {
|
|
1468
|
+
return false;
|
|
1469
|
+
}
|
|
1470
|
+
if (signatureBuffer.length === 0) return false;
|
|
1471
|
+
try {
|
|
1472
|
+
return verify("sha256", Buffer.from(message, "utf8"), key, signatureBuffer);
|
|
1473
|
+
} catch {
|
|
1474
|
+
return false;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
function canonicalEnrollmentMessage(parts) {
|
|
1478
|
+
return [
|
|
1479
|
+
parts.publicKey,
|
|
1480
|
+
parts.model,
|
|
1481
|
+
parts.manufacturer,
|
|
1482
|
+
parts.osVersion,
|
|
1483
|
+
parts.serialNumber ?? "",
|
|
1484
|
+
parts.imei ?? "",
|
|
1485
|
+
parts.macAddress ?? "",
|
|
1486
|
+
parts.androidId ?? "",
|
|
1487
|
+
parts.method,
|
|
1488
|
+
parts.timestamp,
|
|
1489
|
+
parts.challenge
|
|
1490
|
+
].join("|");
|
|
1491
|
+
}
|
|
1492
|
+
function canonicalDeviceRequestMessage(parts) {
|
|
1493
|
+
return [parts.deviceId, parts.timestamp, parts.body, parts.nonce ?? ""].join("|");
|
|
1494
|
+
}
|
|
1495
|
+
async function verifyDeviceRequest(opts) {
|
|
1496
|
+
const device = await opts.mdm.devices.get(opts.deviceId);
|
|
1497
|
+
if (!device) {
|
|
1498
|
+
return { ok: false, reason: "not-found" };
|
|
1499
|
+
}
|
|
1500
|
+
if (!device.publicKey) {
|
|
1501
|
+
return { ok: false, reason: "no-pinned-key", device };
|
|
1502
|
+
}
|
|
1503
|
+
let verified;
|
|
1504
|
+
try {
|
|
1505
|
+
verified = verifyEcdsaSignature(
|
|
1506
|
+
device.publicKey,
|
|
1507
|
+
opts.canonicalMessage,
|
|
1508
|
+
opts.signatureBase64
|
|
1509
|
+
);
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
opts.mdm.logger.child({ component: "device-identity" }).error(
|
|
1512
|
+
{
|
|
1513
|
+
deviceId: opts.deviceId,
|
|
1514
|
+
err: err instanceof Error ? err.message : String(err)
|
|
1515
|
+
},
|
|
1516
|
+
"Pinned public key failed to parse"
|
|
1517
|
+
);
|
|
1518
|
+
return { ok: false, reason: "signature-invalid", device };
|
|
1519
|
+
}
|
|
1520
|
+
if (!verified) {
|
|
1521
|
+
return { ok: false, reason: "signature-invalid", device };
|
|
1522
|
+
}
|
|
1523
|
+
return { ok: true, device };
|
|
1524
|
+
}
|
|
1525
|
+
var InvalidPublicKeyError = class extends Error {
|
|
1526
|
+
constructor(message, cause) {
|
|
1527
|
+
super(message);
|
|
1528
|
+
this.cause = cause;
|
|
1529
|
+
this.name = "InvalidPublicKeyError";
|
|
1530
|
+
}
|
|
1531
|
+
code = "INVALID_PUBLIC_KEY";
|
|
1532
|
+
};
|
|
1533
|
+
var PublicKeyMismatchError = class extends Error {
|
|
1534
|
+
constructor(deviceId) {
|
|
1535
|
+
super(
|
|
1536
|
+
`Device ${deviceId} is already enrolled with a different pinned public key`
|
|
1537
|
+
);
|
|
1538
|
+
this.deviceId = deviceId;
|
|
1539
|
+
this.name = "PublicKeyMismatchError";
|
|
1540
|
+
}
|
|
1541
|
+
code = "PUBLIC_KEY_MISMATCH";
|
|
1542
|
+
};
|
|
1543
|
+
var ChallengeInvalidError = class extends Error {
|
|
1544
|
+
constructor(message, challenge) {
|
|
1545
|
+
super(message);
|
|
1546
|
+
this.challenge = challenge;
|
|
1547
|
+
this.name = "ChallengeInvalidError";
|
|
1548
|
+
}
|
|
1549
|
+
code = "CHALLENGE_INVALID";
|
|
1550
|
+
};
|
|
1410
1551
|
|
|
1411
1552
|
// src/schema.ts
|
|
1412
1553
|
var mdmSchema = {
|
|
@@ -2650,7 +2791,13 @@ function createMDM(config) {
|
|
|
2650
2791
|
`Enrollment method '${request.method}' is not allowed`
|
|
2651
2792
|
);
|
|
2652
2793
|
}
|
|
2653
|
-
|
|
2794
|
+
const isPinnedKeyPath = Boolean(request.publicKey);
|
|
2795
|
+
if (!isPinnedKeyPath && enrollment?.pinnedKey?.required) {
|
|
2796
|
+
throw new EnrollmentError(
|
|
2797
|
+
"Pinned-key enrollment is required but the request carried no publicKey. The agent must generate a Keystore keypair and submit the SPKI public key alongside an ECDSA signature over the canonical enrollment message."
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
if (!isPinnedKeyPath && enrollment?.deviceSecret) {
|
|
2654
2801
|
const isValid = verifyEnrollmentSignature(
|
|
2655
2802
|
request,
|
|
2656
2803
|
enrollment.deviceSecret
|
|
@@ -2659,6 +2806,65 @@ function createMDM(config) {
|
|
|
2659
2806
|
throw new EnrollmentError("Invalid enrollment signature");
|
|
2660
2807
|
}
|
|
2661
2808
|
}
|
|
2809
|
+
let challengeRecord = null;
|
|
2810
|
+
let importedPublicKey = null;
|
|
2811
|
+
if (isPinnedKeyPath) {
|
|
2812
|
+
if (!request.attestationChallenge) {
|
|
2813
|
+
throw new EnrollmentError(
|
|
2814
|
+
"Pinned-key enrollment requires attestationChallenge. Fetch a fresh challenge from /agent/enroll/challenge first."
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
if (!database.consumeEnrollmentChallenge) {
|
|
2818
|
+
throw new EnrollmentError(
|
|
2819
|
+
"Pinned-key enrollment requires an adapter that implements enrollment challenge storage. Upgrade to a database adapter that supports it, or submit an HMAC-signed enrollment instead."
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
try {
|
|
2823
|
+
importedPublicKey = importPublicKeyFromSpki(request.publicKey);
|
|
2824
|
+
} catch (err) {
|
|
2825
|
+
throw new EnrollmentError(
|
|
2826
|
+
err instanceof Error ? `Invalid enrollment public key: ${err.message}` : "Invalid enrollment public key"
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
challengeRecord = await database.consumeEnrollmentChallenge(
|
|
2830
|
+
request.attestationChallenge
|
|
2831
|
+
);
|
|
2832
|
+
if (!challengeRecord) {
|
|
2833
|
+
throw new ChallengeInvalidError(
|
|
2834
|
+
"Enrollment challenge is missing, expired, or already consumed",
|
|
2835
|
+
request.attestationChallenge
|
|
2836
|
+
);
|
|
2837
|
+
}
|
|
2838
|
+
if (challengeRecord.expiresAt.getTime() < Date.now()) {
|
|
2839
|
+
throw new ChallengeInvalidError(
|
|
2840
|
+
"Enrollment challenge has expired",
|
|
2841
|
+
request.attestationChallenge
|
|
2842
|
+
);
|
|
2843
|
+
}
|
|
2844
|
+
const canonical = canonicalEnrollmentMessage({
|
|
2845
|
+
publicKey: request.publicKey,
|
|
2846
|
+
model: request.model,
|
|
2847
|
+
manufacturer: request.manufacturer,
|
|
2848
|
+
osVersion: request.osVersion,
|
|
2849
|
+
serialNumber: request.serialNumber,
|
|
2850
|
+
imei: request.imei,
|
|
2851
|
+
macAddress: request.macAddress,
|
|
2852
|
+
androidId: request.androidId,
|
|
2853
|
+
method: request.method,
|
|
2854
|
+
timestamp: request.timestamp,
|
|
2855
|
+
challenge: request.attestationChallenge
|
|
2856
|
+
});
|
|
2857
|
+
const verified = verifyEcdsaSignature(
|
|
2858
|
+
importedPublicKey,
|
|
2859
|
+
canonical,
|
|
2860
|
+
request.signature
|
|
2861
|
+
);
|
|
2862
|
+
if (!verified) {
|
|
2863
|
+
throw new EnrollmentError(
|
|
2864
|
+
"Invalid enrollment signature (device-pinned-key path)"
|
|
2865
|
+
);
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2662
2868
|
if (enrollment?.validate) {
|
|
2663
2869
|
const isValid = await enrollment.validate(request);
|
|
2664
2870
|
if (!isValid) {
|
|
@@ -2673,13 +2879,23 @@ function createMDM(config) {
|
|
|
2673
2879
|
}
|
|
2674
2880
|
let device = await database.findDeviceByEnrollmentId(enrollmentId);
|
|
2675
2881
|
if (device) {
|
|
2676
|
-
|
|
2882
|
+
if (isPinnedKeyPath && device.publicKey) {
|
|
2883
|
+
if (device.publicKey !== request.publicKey) {
|
|
2884
|
+
throw new PublicKeyMismatchError(device.id);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
const updateInput = {
|
|
2677
2888
|
status: "enrolled",
|
|
2678
2889
|
model: request.model,
|
|
2679
2890
|
manufacturer: request.manufacturer,
|
|
2680
2891
|
osVersion: request.osVersion,
|
|
2681
2892
|
lastSync: /* @__PURE__ */ new Date()
|
|
2682
|
-
}
|
|
2893
|
+
};
|
|
2894
|
+
if (isPinnedKeyPath && !device.publicKey) {
|
|
2895
|
+
updateInput.publicKey = request.publicKey;
|
|
2896
|
+
updateInput.enrollmentMethod = "pinned-key";
|
|
2897
|
+
}
|
|
2898
|
+
device = await database.updateDevice(device.id, updateInput);
|
|
2683
2899
|
} else if (enrollment?.autoEnroll) {
|
|
2684
2900
|
device = await database.createDevice({
|
|
2685
2901
|
enrollmentId,
|
|
@@ -2692,6 +2908,12 @@ function createMDM(config) {
|
|
|
2692
2908
|
androidId: request.androidId,
|
|
2693
2909
|
policyId: request.policyId || enrollment.defaultPolicyId
|
|
2694
2910
|
});
|
|
2911
|
+
if (isPinnedKeyPath) {
|
|
2912
|
+
device = await database.updateDevice(device.id, {
|
|
2913
|
+
publicKey: request.publicKey,
|
|
2914
|
+
enrollmentMethod: "pinned-key"
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2695
2917
|
if (enrollment.defaultGroupId) {
|
|
2696
2918
|
await database.addDeviceToGroup(device.id, enrollment.defaultGroupId);
|
|
2697
2919
|
}
|
|
@@ -2706,6 +2928,12 @@ function createMDM(config) {
|
|
|
2706
2928
|
macAddress: request.macAddress,
|
|
2707
2929
|
androidId: request.androidId
|
|
2708
2930
|
});
|
|
2931
|
+
if (isPinnedKeyPath) {
|
|
2932
|
+
device = await database.updateDevice(device.id, {
|
|
2933
|
+
publicKey: request.publicKey,
|
|
2934
|
+
enrollmentMethod: "pinned-key"
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2709
2937
|
} else {
|
|
2710
2938
|
throw new EnrollmentError(
|
|
2711
2939
|
"Device not registered and auto-enroll is disabled"
|
|
@@ -2993,6 +3221,6 @@ function generateDeviceToken(deviceId, secret, expirationSeconds) {
|
|
|
2993
3221
|
return `${header}.${payload}.${signature}`;
|
|
2994
3222
|
}
|
|
2995
3223
|
|
|
2996
|
-
export { AGENT_PROTOCOL_HEADER, AGENT_PROTOCOL_V2, ApplicationNotFoundError, AuthenticationError, AuthorizationError, CommandNotFoundError, DeviceNotFoundError, EnrollmentError, GroupNotFoundError, MDMError, PolicyNotFoundError, RoleNotFoundError, TenantNotFoundError, UserNotFoundError, ValidationError, agentFail, agentOk, camelToSnake, createAuditManager, createAuthorizationManager, createConsoleLogger, createDashboardManager, createMDM, createMemoryPluginStorageAdapter, createMessageQueueManager, createPluginKey, createPluginStorageAdapter, createScheduleManager, createSilentLogger, createTenantManager, createWebhookManager, getColumnNames, getPrimaryKey, getTableNames, mdmSchema, parsePluginKey, snakeToCamel, transformToCamelCase, transformToSnakeCase, verifyEnrollmentSignature, verifyWebhookSignature, wantsAgentProtocolV2 };
|
|
3224
|
+
export { AGENT_PROTOCOL_HEADER, AGENT_PROTOCOL_V2, ApplicationNotFoundError, AuthenticationError, AuthorizationError, ChallengeInvalidError, CommandNotFoundError, DeviceNotFoundError, EnrollmentError, GroupNotFoundError, InvalidPublicKeyError, MDMError, PolicyNotFoundError, PublicKeyMismatchError, RoleNotFoundError, TenantNotFoundError, UserNotFoundError, ValidationError, agentFail, agentOk, camelToSnake, canonicalDeviceRequestMessage, canonicalEnrollmentMessage, createAuditManager, createAuthorizationManager, createConsoleLogger, createDashboardManager, createMDM, createMemoryPluginStorageAdapter, createMessageQueueManager, createPluginKey, createPluginStorageAdapter, createScheduleManager, createSilentLogger, createTenantManager, createWebhookManager, getColumnNames, getPrimaryKey, getTableNames, importPublicKeyFromSpki, mdmSchema, parsePluginKey, snakeToCamel, transformToCamelCase, transformToSnakeCase, verifyDeviceRequest, verifyEcdsaSignature, verifyEnrollmentSignature, verifyWebhookSignature, wantsAgentProtocolV2 };
|
|
2997
3225
|
//# sourceMappingURL=index.js.map
|
|
2998
3226
|
//# sourceMappingURL=index.js.map
|