@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
|
@@ -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
|
-
//
|
|
939
|
-
if
|
|
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
|
-
|
|
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(
|