@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.
@@ -0,0 +1,338 @@
1
+ /**
2
+ * OpenMDM Device Identity
3
+ *
4
+ * Device-pinned asymmetric identity, using an ECDSA P-256 keypair the
5
+ * device generates in its own Keystore and registers with the server on
6
+ * first enrollment. After pinning, every consumer can verify a signed
7
+ * request against the same pinned public key — no shared HMAC secret,
8
+ * no APK extraction footgun, no dependence on Google hardware
9
+ * attestation (which most non-GMS fleet hardware cannot produce).
10
+ *
11
+ * This module is the reusable primitive. `@openmdm/core` uses it to
12
+ * gate `/agent/enroll` and will use it for `/agent/*` in Phase 2c.
13
+ * External consumers (midiamob's `deviceValidation.ts`, other custom
14
+ * servers) import the same functions to verify requests against the
15
+ * same pinned key — one device identity, many consumers.
16
+ *
17
+ * Why zero dependencies: Node's built-in `node:crypto` supports EC
18
+ * P-256 SPKI import and `crypto.verify('sha256', ...)` over DER-encoded
19
+ * signatures, which is the default format the Android Keystore
20
+ * produces. We deliberately do not pull in `@peculiar/*` or `node-forge`
21
+ * for this primitive — the surface area we need is small enough that
22
+ * the built-in is the right call.
23
+ *
24
+ * @see docs/concepts/enrollment for the full flow
25
+ * @see docs/proposals/phase-2b-rollout for the Android + rollout story
26
+ */
27
+
28
+ import { createPublicKey, verify as cryptoVerify, KeyObject } from 'crypto';
29
+ import type { Device, DeviceIdentityVerification, MDMInstance } from './types';
30
+
31
+ // ============================================
32
+ // Low-level: imports and signature verification
33
+ // ============================================
34
+
35
+ /**
36
+ * Import an EC P-256 public key from base64-encoded SubjectPublicKeyInfo
37
+ * (SPKI) bytes — the standard on-wire format the Android Keystore
38
+ * produces when you call `certificate.publicKey.encoded` on a
39
+ * `KeyStore.getCertificate(alias)` result.
40
+ *
41
+ * Throws `InvalidPublicKeyError` on any parse failure. This is a
42
+ * security boundary — we do NOT return `null` on malformed input,
43
+ * because a caller that forgot to handle the null case would silently
44
+ * treat bad keys as "no key configured" and fall through to an
45
+ * insecure path.
46
+ */
47
+ export function importPublicKeyFromSpki(spkiBase64: string): KeyObject {
48
+ let buffer: Buffer;
49
+ try {
50
+ buffer = Buffer.from(spkiBase64, 'base64');
51
+ } catch (err) {
52
+ throw new InvalidPublicKeyError(
53
+ 'Public key is not valid base64',
54
+ err instanceof Error ? err : undefined,
55
+ );
56
+ }
57
+ if (buffer.length === 0) {
58
+ throw new InvalidPublicKeyError('Public key is empty');
59
+ }
60
+ try {
61
+ const key = createPublicKey({
62
+ key: buffer,
63
+ format: 'der',
64
+ type: 'spki',
65
+ });
66
+ const asymmetricKeyType = key.asymmetricKeyType;
67
+ if (asymmetricKeyType !== 'ec') {
68
+ throw new InvalidPublicKeyError(
69
+ `Expected EC key, got ${asymmetricKeyType ?? 'unknown'}`,
70
+ );
71
+ }
72
+ const curve = (key.asymmetricKeyDetails as { namedCurve?: string } | undefined)
73
+ ?.namedCurve;
74
+ if (curve && curve !== 'prime256v1' && curve !== 'P-256') {
75
+ // Node exposes the curve name as `prime256v1` (the OpenSSL spelling)
76
+ // for EC P-256. Accept both for forward-compat but reject anything
77
+ // else loudly — weaker curves should not silently verify.
78
+ throw new InvalidPublicKeyError(
79
+ `Unsupported EC curve: ${curve}. Only P-256 is accepted.`,
80
+ );
81
+ }
82
+ return key;
83
+ } catch (err) {
84
+ if (err instanceof InvalidPublicKeyError) throw err;
85
+ throw new InvalidPublicKeyError(
86
+ 'Failed to parse SPKI public key',
87
+ err instanceof Error ? err : undefined,
88
+ );
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Verify an ECDSA-P256 signature over a message using a previously-
94
+ * imported or raw SPKI public key.
95
+ *
96
+ * Signature must be DER-encoded — the default Android Keystore
97
+ * produces DER, and `Signature.sign()` on JVM/Kotlin returns DER, so
98
+ * this matches what every reasonable agent sends on the wire.
99
+ *
100
+ * Returns `true` iff the signature is valid. Never throws on a bad
101
+ * signature (that is the whole point of a verify call). Throws only
102
+ * on an invalid public-key encoding, because that indicates a caller
103
+ * bug rather than a forged request.
104
+ */
105
+ export function verifyEcdsaSignature(
106
+ publicKey: KeyObject | string,
107
+ message: string,
108
+ signatureBase64: string,
109
+ ): boolean {
110
+ const key =
111
+ typeof publicKey === 'string' ? importPublicKeyFromSpki(publicKey) : publicKey;
112
+ let signatureBuffer: Buffer;
113
+ try {
114
+ signatureBuffer = Buffer.from(signatureBase64, 'base64');
115
+ } catch {
116
+ return false;
117
+ }
118
+ if (signatureBuffer.length === 0) return false;
119
+
120
+ try {
121
+ return cryptoVerify('sha256', Buffer.from(message, 'utf8'), key, signatureBuffer);
122
+ } catch {
123
+ // `crypto.verify` throws only on malformed DER, not on wrong
124
+ // signatures. Treat both as "not verified" — the caller can tell
125
+ // them apart by checking the public key import path separately.
126
+ return false;
127
+ }
128
+ }
129
+
130
+ // ============================================
131
+ // Canonical message
132
+ // ============================================
133
+
134
+ /**
135
+ * Build the canonical message that an enrollment signature covers.
136
+ *
137
+ * Staying in lockstep with `@openmdm/client` and with the Android
138
+ * agent is load-bearing — any change here is a wire break across
139
+ * every enrolled device. The contract test in
140
+ * `packages/core/tests/device-identity.test.ts` guards against drift.
141
+ *
142
+ * Shape (order matters):
143
+ *
144
+ * publicKey |
145
+ * model | manufacturer | osVersion |
146
+ * serialNumber | imei | macAddress | androidId |
147
+ * method | timestamp | challenge
148
+ *
149
+ * The public key is prepended (rather than appended) because it's the
150
+ * field most likely to be the whole point of the message — putting it
151
+ * first makes the signature's intent visible at a glance in logs.
152
+ */
153
+ export function canonicalEnrollmentMessage(parts: {
154
+ publicKey: string;
155
+ model: string;
156
+ manufacturer: string;
157
+ osVersion: string;
158
+ serialNumber?: string;
159
+ imei?: string;
160
+ macAddress?: string;
161
+ androidId?: string;
162
+ method: string;
163
+ timestamp: string;
164
+ challenge: string;
165
+ }): string {
166
+ return [
167
+ parts.publicKey,
168
+ parts.model,
169
+ parts.manufacturer,
170
+ parts.osVersion,
171
+ parts.serialNumber ?? '',
172
+ parts.imei ?? '',
173
+ parts.macAddress ?? '',
174
+ parts.androidId ?? '',
175
+ parts.method,
176
+ parts.timestamp,
177
+ parts.challenge,
178
+ ].join('|');
179
+ }
180
+
181
+ /**
182
+ * Build the canonical message that a *post-enrollment* request
183
+ * signature covers. Consumers (openmdm's `/agent/*` routes,
184
+ * midiamob's `deviceValidation.ts`, any custom server) call this
185
+ * with the fields they want committed to the signature.
186
+ *
187
+ * The shape is deliberately narrower than the enrollment form — only
188
+ * the parts every request has in common.
189
+ *
190
+ * deviceId | timestamp | body | nonce
191
+ *
192
+ * `nonce` is optional; pass an empty string when the request does not
193
+ * carry a challenge. Replay protection on non-enrollment traffic is
194
+ * the caller's job — if your server already has a timestamp window
195
+ * check, you don't need a nonce per request.
196
+ */
197
+ export function canonicalDeviceRequestMessage(parts: {
198
+ deviceId: string;
199
+ timestamp: string;
200
+ body: string;
201
+ nonce?: string;
202
+ }): string {
203
+ return [parts.deviceId, parts.timestamp, parts.body, parts.nonce ?? ''].join('|');
204
+ }
205
+
206
+ // ============================================
207
+ // High-level: verifyDeviceRequest primitive
208
+ // ============================================
209
+
210
+ /**
211
+ * Verify a signed request from an enrolled device against the
212
+ * public key pinned on that device's row.
213
+ *
214
+ * This is the primitive every consumer of device-pinned-key identity
215
+ * calls. It performs exactly the checks required to know the request
216
+ * came from the device that originally enrolled, in constant-ish
217
+ * time:
218
+ *
219
+ * 1. Look up the device by id.
220
+ * 2. Confirm the device has a pinned public key (refusing silently
221
+ * if not — a device without a pinned key is still on the legacy
222
+ * HMAC path and cannot be verified here).
223
+ * 3. Verify the ECDSA signature over the provided canonical message.
224
+ *
225
+ * Returns a tagged union so callers can react to the specific failure
226
+ * mode:
227
+ *
228
+ * - `not-found` — the device id doesn't exist. Almost always a bug
229
+ * in the caller, or a stolen/revoked device id.
230
+ * Return 401 to the client.
231
+ * - `no-pinned-key` — the device is still on the HMAC path. Callers
232
+ * should fall through to their legacy verifier
233
+ * (or fail, if the caller has already migrated).
234
+ * - `signature-invalid` — the signature did not verify against the
235
+ * pinned key. Return 401. **Do NOT** re-pin the
236
+ * submitted public key in response to a failure
237
+ * here — that's how re-pinning becomes a hijack.
238
+ */
239
+ export async function verifyDeviceRequest(opts: {
240
+ mdm: MDMInstance;
241
+ deviceId: string;
242
+ canonicalMessage: string;
243
+ signatureBase64: string;
244
+ }): Promise<DeviceIdentityVerification> {
245
+ const device = await opts.mdm.devices.get(opts.deviceId);
246
+ if (!device) {
247
+ return { ok: false, reason: 'not-found' };
248
+ }
249
+
250
+ if (!device.publicKey) {
251
+ return { ok: false, reason: 'no-pinned-key', device };
252
+ }
253
+
254
+ let verified: boolean;
255
+ try {
256
+ verified = verifyEcdsaSignature(
257
+ device.publicKey,
258
+ opts.canonicalMessage,
259
+ opts.signatureBase64,
260
+ );
261
+ } catch (err) {
262
+ // A pinned key that fails to parse is a data-integrity problem,
263
+ // not a forged request. We log it through the mdm logger so
264
+ // operators can see it, then treat the request as unverified.
265
+ opts.mdm.logger
266
+ .child({ component: 'device-identity' })
267
+ .error(
268
+ {
269
+ deviceId: opts.deviceId,
270
+ err: err instanceof Error ? err.message : String(err),
271
+ },
272
+ 'Pinned public key failed to parse',
273
+ );
274
+ return { ok: false, reason: 'signature-invalid', device };
275
+ }
276
+
277
+ if (!verified) {
278
+ return { ok: false, reason: 'signature-invalid', device };
279
+ }
280
+
281
+ return { ok: true, device };
282
+ }
283
+
284
+ // ============================================
285
+ // Errors
286
+ // ============================================
287
+
288
+ /**
289
+ * Thrown when a submitted public key cannot be parsed. This is a
290
+ * caller-facing error — the device sent something that is not a
291
+ * well-formed SPKI EC P-256 public key.
292
+ */
293
+ export class InvalidPublicKeyError extends Error {
294
+ readonly code = 'INVALID_PUBLIC_KEY';
295
+
296
+ constructor(
297
+ message: string,
298
+ public readonly cause?: Error,
299
+ ) {
300
+ super(message);
301
+ this.name = 'InvalidPublicKeyError';
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Thrown when a device attempts to re-enroll with a public key that
307
+ * does not match the one originally pinned for its enrollment id.
308
+ *
309
+ * This is the core "device identity continuity" check. The server
310
+ * will NEVER automatically re-pin on mismatch — rebinding a device
311
+ * identity requires an explicit admin action (future work).
312
+ */
313
+ export class PublicKeyMismatchError extends Error {
314
+ readonly code = 'PUBLIC_KEY_MISMATCH';
315
+
316
+ constructor(public readonly deviceId: string) {
317
+ super(
318
+ `Device ${deviceId} is already enrolled with a different pinned public key`,
319
+ );
320
+ this.name = 'PublicKeyMismatchError';
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Thrown when an enrollment attempts to use a challenge that is
326
+ * missing, expired, or already consumed.
327
+ */
328
+ export class ChallengeInvalidError extends Error {
329
+ readonly code = 'CHALLENGE_INVALID';
330
+
331
+ constructor(
332
+ message: string,
333
+ public readonly challenge?: string,
334
+ ) {
335
+ super(message);
336
+ this.name = 'ChallengeInvalidError';
337
+ }
338
+ }
package/src/index.ts CHANGED
@@ -74,6 +74,7 @@ import type {
74
74
  GroupTreeNode,
75
75
  GroupHierarchyStats,
76
76
  Logger,
77
+ EnrollmentChallenge,
77
78
  } from './types';
78
79
  import {
79
80
  DeviceNotFoundError,
@@ -90,6 +91,16 @@ import { createMessageQueueManager } from './queue';
90
91
  import { createDashboardManager } from './dashboard';
91
92
  import { createPluginStorageAdapter, createMemoryPluginStorageAdapter } from './plugin-storage';
92
93
  import { createConsoleLogger, createSilentLogger } from './logger';
94
+ import {
95
+ importPublicKeyFromSpki,
96
+ verifyEcdsaSignature,
97
+ canonicalEnrollmentMessage,
98
+ canonicalDeviceRequestMessage,
99
+ verifyDeviceRequest,
100
+ InvalidPublicKeyError,
101
+ PublicKeyMismatchError,
102
+ ChallengeInvalidError,
103
+ } from './device-identity';
93
104
 
94
105
  // Re-export all types
95
106
  export * from './types';
@@ -99,6 +110,18 @@ export { createWebhookManager, verifyWebhookSignature } from './webhooks';
99
110
  export type { WebhookPayload } from './webhooks';
100
111
  export { createConsoleLogger, createSilentLogger } from './logger';
101
112
 
113
+ // Device identity (Phase 2b)
114
+ export {
115
+ importPublicKeyFromSpki,
116
+ verifyEcdsaSignature,
117
+ canonicalEnrollmentMessage,
118
+ canonicalDeviceRequestMessage,
119
+ verifyDeviceRequest,
120
+ InvalidPublicKeyError,
121
+ PublicKeyMismatchError,
122
+ ChallengeInvalidError,
123
+ } from './device-identity';
124
+
102
125
  // Re-export enterprise manager factories
103
126
  export { createTenantManager } from './tenant';
104
127
  export { createAuthorizationManager } from './authorization';
@@ -935,8 +958,24 @@ export function createMDM(config: MDMConfig): MDMInstance {
935
958
  );
936
959
  }
937
960
 
938
- // Verify signature if secret is configured
939
- if (enrollment?.deviceSecret) {
961
+ // Determine which enrollment path the request is asking for.
962
+ // The presence of `publicKey` is the signal: if the device
963
+ // supplies a public key, it is attempting the Phase 2b
964
+ // device-pinned-key path and must also supply a valid
965
+ // attestation challenge. Otherwise we fall through to the
966
+ // legacy HMAC path.
967
+ const isPinnedKeyPath = Boolean(request.publicKey);
968
+
969
+ if (!isPinnedKeyPath && enrollment?.pinnedKey?.required) {
970
+ throw new EnrollmentError(
971
+ 'Pinned-key enrollment is required but the request carried no publicKey. ' +
972
+ 'The agent must generate a Keystore keypair and submit the SPKI public key ' +
973
+ 'alongside an ECDSA signature over the canonical enrollment message.',
974
+ );
975
+ }
976
+
977
+ // HMAC path (Phase 2a): unchanged behavior.
978
+ if (!isPinnedKeyPath && enrollment?.deviceSecret) {
940
979
  const isValid = verifyEnrollmentSignature(
941
980
  request,
942
981
  enrollment.deviceSecret
@@ -946,6 +985,81 @@ export function createMDM(config: MDMConfig): MDMInstance {
946
985
  }
947
986
  }
948
987
 
988
+ // Pinned-key path (Phase 2b).
989
+ let challengeRecord: EnrollmentChallenge | null = null;
990
+ let importedPublicKey: ReturnType<typeof importPublicKeyFromSpki> | null = null;
991
+ if (isPinnedKeyPath) {
992
+ if (!request.attestationChallenge) {
993
+ throw new EnrollmentError(
994
+ 'Pinned-key enrollment requires attestationChallenge. ' +
995
+ 'Fetch a fresh challenge from /agent/enroll/challenge first.',
996
+ );
997
+ }
998
+ if (!database.consumeEnrollmentChallenge) {
999
+ throw new EnrollmentError(
1000
+ 'Pinned-key enrollment requires an adapter that implements enrollment ' +
1001
+ 'challenge storage. Upgrade to a database adapter that supports it, or ' +
1002
+ 'submit an HMAC-signed enrollment instead.',
1003
+ );
1004
+ }
1005
+
1006
+ // Parse the public key first — if it's malformed the signature
1007
+ // cannot possibly verify and we want a specific error.
1008
+ try {
1009
+ importedPublicKey = importPublicKeyFromSpki(request.publicKey as string);
1010
+ } catch (err) {
1011
+ throw new EnrollmentError(
1012
+ err instanceof Error
1013
+ ? `Invalid enrollment public key: ${err.message}`
1014
+ : 'Invalid enrollment public key',
1015
+ );
1016
+ }
1017
+
1018
+ // Atomically consume the challenge. This must happen BEFORE
1019
+ // signature verification, otherwise two concurrent requests
1020
+ // with the same challenge could both succeed.
1021
+ challengeRecord = await database.consumeEnrollmentChallenge(
1022
+ request.attestationChallenge,
1023
+ );
1024
+ if (!challengeRecord) {
1025
+ throw new ChallengeInvalidError(
1026
+ 'Enrollment challenge is missing, expired, or already consumed',
1027
+ request.attestationChallenge,
1028
+ );
1029
+ }
1030
+ if (challengeRecord.expiresAt.getTime() < Date.now()) {
1031
+ throw new ChallengeInvalidError(
1032
+ 'Enrollment challenge has expired',
1033
+ request.attestationChallenge,
1034
+ );
1035
+ }
1036
+
1037
+ const canonical = canonicalEnrollmentMessage({
1038
+ publicKey: request.publicKey as string,
1039
+ model: request.model,
1040
+ manufacturer: request.manufacturer,
1041
+ osVersion: request.osVersion,
1042
+ serialNumber: request.serialNumber,
1043
+ imei: request.imei,
1044
+ macAddress: request.macAddress,
1045
+ androidId: request.androidId,
1046
+ method: request.method,
1047
+ timestamp: request.timestamp,
1048
+ challenge: request.attestationChallenge,
1049
+ });
1050
+
1051
+ const verified = verifyEcdsaSignature(
1052
+ importedPublicKey,
1053
+ canonical,
1054
+ request.signature,
1055
+ );
1056
+ if (!verified) {
1057
+ throw new EnrollmentError(
1058
+ 'Invalid enrollment signature (device-pinned-key path)',
1059
+ );
1060
+ }
1061
+ }
1062
+
949
1063
  // Custom validation
950
1064
  if (enrollment?.validate) {
951
1065
  const isValid = await enrollment.validate(request);
@@ -971,14 +1085,40 @@ export function createMDM(config: MDMConfig): MDMInstance {
971
1085
  let device = await database.findDeviceByEnrollmentId(enrollmentId);
972
1086
 
973
1087
  if (device) {
974
- // Device re-enrolling
975
- device = await database.updateDevice(device.id, {
1088
+ // Device re-enrolling. If the device is already on the
1089
+ // pinned-key path, the submitted public key MUST match the
1090
+ // pinned one — otherwise we reject loudly. This is how we
1091
+ // prevent an attacker who extracted the enrollment secret
1092
+ // from hijacking an enrolled device's identity: without the
1093
+ // original private key they cannot produce a valid signature,
1094
+ // and even if they could (via a forged HMAC fallback), the
1095
+ // pinned key still identifies the legitimate device.
1096
+ if (isPinnedKeyPath && device.publicKey) {
1097
+ if (device.publicKey !== request.publicKey) {
1098
+ throw new PublicKeyMismatchError(device.id);
1099
+ }
1100
+ }
1101
+
1102
+ const updateInput: UpdateDeviceInput = {
976
1103
  status: 'enrolled',
977
1104
  model: request.model,
978
1105
  manufacturer: request.manufacturer,
979
1106
  osVersion: request.osVersion,
980
1107
  lastSync: new Date(),
981
- });
1108
+ };
1109
+
1110
+ // Pin the key on first pinned-key enrollment for a device
1111
+ // that originally enrolled on HMAC. This is the migration
1112
+ // path: a device that used to sign with the shared secret
1113
+ // can upgrade by sending its freshly-generated public key on
1114
+ // its next enrollment, and the server will pin it from then
1115
+ // on.
1116
+ if (isPinnedKeyPath && !device.publicKey) {
1117
+ updateInput.publicKey = request.publicKey;
1118
+ updateInput.enrollmentMethod = 'pinned-key';
1119
+ }
1120
+
1121
+ device = await database.updateDevice(device.id, updateInput);
982
1122
  } else if (enrollment?.autoEnroll) {
983
1123
  // Auto-create device
984
1124
  device = await database.createDevice({
@@ -993,6 +1133,17 @@ export function createMDM(config: MDMConfig): MDMInstance {
993
1133
  policyId: request.policyId || enrollment.defaultPolicyId,
994
1134
  });
995
1135
 
1136
+ // Pin the public key on first enrollment for pinned-key path.
1137
+ // `CreateDeviceInput` deliberately doesn't carry auth fields —
1138
+ // we keep auth state a post-creation concern so legacy
1139
+ // adapters don't have to know about it.
1140
+ if (isPinnedKeyPath) {
1141
+ device = await database.updateDevice(device.id, {
1142
+ publicKey: request.publicKey,
1143
+ enrollmentMethod: 'pinned-key',
1144
+ });
1145
+ }
1146
+
996
1147
  // Add to default group if configured
997
1148
  if (enrollment.defaultGroupId) {
998
1149
  await database.addDeviceToGroup(device.id, enrollment.defaultGroupId);
@@ -1009,6 +1160,15 @@ export function createMDM(config: MDMConfig): MDMInstance {
1009
1160
  macAddress: request.macAddress,
1010
1161
  androidId: request.androidId,
1011
1162
  });
1163
+
1164
+ // Pin the public key even for pending devices — we want to
1165
+ // know which key originally enrolled once an admin approves.
1166
+ if (isPinnedKeyPath) {
1167
+ device = await database.updateDevice(device.id, {
1168
+ publicKey: request.publicKey,
1169
+ enrollmentMethod: 'pinned-key',
1170
+ });
1171
+ }
1012
1172
  // Status remains 'pending'
1013
1173
  } else {
1014
1174
  throw new EnrollmentError(