@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 CHANGED
@@ -1,6 +1,7 @@
1
- import { WebhookConfig, Logger, MDMEvent, WebhookEndpoint, EventType, DatabaseAdapter, TenantManager, AuthorizationManager, AuditConfig, AuditManager, ScheduleManager, MessageQueueManager, DashboardManager, PluginStorageAdapter, MDMConfig, MDMInstance, 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, 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
- if (enrollment?.deviceSecret) {
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
- device = await database.updateDevice(device.id, {
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