@openmdm/core 0.7.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/src/dashboard.ts CHANGED
@@ -16,6 +16,34 @@ import type {
16
16
  DeviceStatus,
17
17
  } from './types';
18
18
 
19
+ /**
20
+ * Throws if a tenantId is supplied to a dashboard method whose
21
+ * database adapter does not implement the tenant-scoped version.
22
+ *
23
+ * The old behavior — silently ignoring the tenantId and returning
24
+ * global stats — was a data-leak footgun in multi-tenant deployments.
25
+ * We would rather fail loudly than return fleet-wide numbers to a
26
+ * caller who thought they were asking about one tenant.
27
+ *
28
+ * The audit recommended this as a backstop until core resources gain
29
+ * a real `tenantId` column. Once that lands, this guard becomes
30
+ * redundant — the fallback paths will be able to filter themselves.
31
+ */
32
+ function assertNoTenantScopeRequested(
33
+ tenantId: string | undefined,
34
+ method: string,
35
+ ): void {
36
+ if (tenantId) {
37
+ throw new Error(
38
+ `DashboardManager.${method} was called with a tenantId but the ` +
39
+ 'database adapter does not implement tenant-scoped dashboard ' +
40
+ 'queries. Implement the matching DatabaseAdapter method, or omit ' +
41
+ 'tenantId to accept global stats. See ' +
42
+ 'docs/proposals/tenant-rbac-audit.md for context.',
43
+ );
44
+ }
45
+ }
46
+
19
47
  /**
20
48
  * Create a DashboardManager instance
21
49
  */
@@ -27,6 +55,10 @@ export function createDashboardManager(db: DatabaseAdapter): DashboardManager {
27
55
  return db.getDashboardStats(_tenantId);
28
56
  }
29
57
 
58
+ // No tenant-scoped path available in the fallback. Refuse
59
+ // rather than silently returning global stats.
60
+ assertNoTenantScopeRequested(_tenantId, 'getStats');
61
+
30
62
  // Fallback: compute from individual queries
31
63
  const devices = await db.listDevices({
32
64
  limit: 10000, // Get all for counting
@@ -94,6 +126,8 @@ export function createDashboardManager(db: DatabaseAdapter): DashboardManager {
94
126
  return db.getDeviceStatusBreakdown(_tenantId);
95
127
  }
96
128
 
129
+ assertNoTenantScopeRequested(_tenantId, 'getDeviceStatusBreakdown');
130
+
97
131
  const devices = await db.listDevices({
98
132
  limit: 10000,
99
133
  });
@@ -139,6 +173,8 @@ export function createDashboardManager(db: DatabaseAdapter): DashboardManager {
139
173
  return db.getEnrollmentTrend(days, _tenantId);
140
174
  }
141
175
 
176
+ assertNoTenantScopeRequested(_tenantId, 'getEnrollmentTrend');
177
+
142
178
  // Generate trend data from event history
143
179
  const now = new Date();
144
180
  const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
@@ -215,6 +251,8 @@ export function createDashboardManager(db: DatabaseAdapter): DashboardManager {
215
251
  return db.getCommandSuccessRates(_tenantId);
216
252
  }
217
253
 
254
+ assertNoTenantScopeRequested(_tenantId, 'getCommandSuccessRates');
255
+
218
256
  const now = new Date();
219
257
  const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
220
258
 
@@ -276,6 +314,8 @@ export function createDashboardManager(db: DatabaseAdapter): DashboardManager {
276
314
  return db.getAppInstallationSummary(_tenantId);
277
315
  }
278
316
 
317
+ assertNoTenantScopeRequested(_tenantId, 'getAppInstallationSummary');
318
+
279
319
  // Get all apps
280
320
  const apps = await db.listApplications();
281
321
  const appMap = new Map(apps.map((a) => [a.packageName, a]));
@@ -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
+ }