@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/dist/index.d.ts +205 -4
- package/dist/index.js +339 -35
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +203 -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 +222 -27
- package/src/logger.ts +98 -0
- package/src/plugin-storage.ts +25 -5
- package/src/types.ts +216 -1
- package/src/webhooks.ts +15 -4
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
|
+
}
|