@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/src/types.ts CHANGED
@@ -32,6 +32,23 @@ export interface Device {
32
32
  lastHeartbeat?: Date | null;
33
33
  lastSync?: Date | null;
34
34
 
35
+ // Device identity (Phase 2b — device-pinned ECDSA P-256 key)
36
+ /**
37
+ * Base64-encoded SPKI public key the device registered on first
38
+ * enrollment. Requests from this device can be verified against
39
+ * this key via `verifyDeviceRequest` / `verifyEcdsaSignature` from
40
+ * `@openmdm/core`. `null` on devices that enrolled via the legacy
41
+ * HMAC path and have never been migrated.
42
+ */
43
+ publicKey?: string | null;
44
+ /**
45
+ * How the device originally enrolled. `'hmac'` for the legacy
46
+ * shared-secret path; `'pinned-key'` for the device-pinned ECDSA
47
+ * path. `null` on pre-Phase-2b device rows that predate the
48
+ * column (treated as `'hmac'`).
49
+ */
50
+ enrollmentMethod?: 'hmac' | 'pinned-key' | null;
51
+
35
52
  // Telemetry
36
53
  batteryLevel?: number | null;
37
54
  storageUsed?: number | null;
@@ -94,6 +111,10 @@ export interface UpdateDeviceInput {
94
111
  location?: DeviceLocation;
95
112
  tags?: Record<string, string>;
96
113
  metadata?: Record<string, unknown>;
114
+ /** Phase 2b — pin a new public key on first enroll. */
115
+ publicKey?: string | null;
116
+ /** Phase 2b — record which auth path the device enrolled via. */
117
+ enrollmentMethod?: 'hmac' | 'pinned-key' | null;
97
118
  }
98
119
 
99
120
  export interface DeviceFilter {
@@ -532,8 +553,45 @@ export interface EnrollmentRequest {
532
553
  // Enrollment details
533
554
  method: EnrollmentMethod;
534
555
  timestamp: string;
556
+
557
+ /**
558
+ * Signature over the canonical enrollment message.
559
+ *
560
+ * Phase 2a (HMAC path, backwards-compatible): hex-encoded
561
+ * HMAC-SHA256 of the nine-field pipe-delimited canonical form
562
+ * (see `concepts/enrollment`).
563
+ *
564
+ * Phase 2b (device-pinned-key path, preferred): base64-encoded
565
+ * DER ECDSA-P256 signature produced by the device's Keystore
566
+ * private key, over `canonicalEnrollmentMessage(...)` including
567
+ * the public key and challenge. The server distinguishes the
568
+ * two paths by whether `publicKey` is present on the request.
569
+ */
535
570
  signature: string;
536
571
 
572
+ /**
573
+ * Base64-encoded SPKI public key (EC P-256) the device generated
574
+ * in its Keystore. When present, enrollment follows the Phase 2b
575
+ * device-pinned-key path and `signature` must verify as an ECDSA
576
+ * signature against this key. The server pins this key on the
577
+ * device row on first successful enroll; any future enroll
578
+ * attempting a different key for the same `enrollmentId` is
579
+ * rejected with `PublicKeyMismatchError`.
580
+ *
581
+ * Omit for the legacy HMAC path. Callers that want to migrate a
582
+ * fleet gradually can run both paths in parallel.
583
+ */
584
+ publicKey?: string;
585
+
586
+ /**
587
+ * Opaque challenge issued by `GET /agent/enroll/challenge`. Must
588
+ * be present whenever `publicKey` is present — the server uses
589
+ * it to prevent replay of captured enrollment payloads. The
590
+ * challenge is single-use: the server consumes it on first
591
+ * successful verify.
592
+ */
593
+ attestationChallenge?: string;
594
+
537
595
  // Optional pre-assigned policy/group
538
596
  policyId?: string;
539
597
  groupId?: string;
@@ -551,6 +609,43 @@ export interface EnrollmentResponse {
551
609
  tokenExpiresAt?: Date;
552
610
  }
553
611
 
612
+ // ============================================
613
+ // Device Identity (Phase 2b)
614
+ // ============================================
615
+
616
+ /**
617
+ * Single-use nonce issued by `GET /agent/enroll/challenge` and
618
+ * consumed on first successful verify of a device-pinned-key
619
+ * enrollment. A persisted record; the `consume*` adapter methods
620
+ * enforce the single-use invariant.
621
+ */
622
+ export interface EnrollmentChallenge {
623
+ challenge: string;
624
+ expiresAt: Date;
625
+ consumedAt?: Date | null;
626
+ createdAt: Date;
627
+ }
628
+
629
+ /**
630
+ * Result of calling `verifyDeviceRequest`. Callers pattern-match on
631
+ * `ok` and, when `false`, on `reason` to decide their response
632
+ * shape:
633
+ *
634
+ * - `not-found` — unknown device id. Return 401.
635
+ * - `no-pinned-key` — device exists but never migrated off the
636
+ * legacy HMAC path. Caller should fall back
637
+ * to their HMAC verifier, or fail if
638
+ * they've completed their migration.
639
+ * - `signature-invalid` — signature did not verify against the
640
+ * pinned key. Return 401. Never re-pin
641
+ * in response to this.
642
+ */
643
+ export type DeviceIdentityVerification =
644
+ | { ok: true; device: Device }
645
+ | { ok: false; reason: 'not-found' }
646
+ | { ok: false; reason: 'no-pinned-key'; device: Device }
647
+ | { ok: false; reason: 'signature-invalid'; device: Device };
648
+
554
649
  export interface PushConfig {
555
650
  provider: 'fcm' | 'mqtt' | 'websocket' | 'polling';
556
651
  fcmSenderId?: string;
@@ -844,7 +939,7 @@ export interface PushProviderConfig {
844
939
  export interface EnrollmentConfig {
845
940
  /** Auto-enroll devices with valid signature */
846
941
  autoEnroll?: boolean;
847
- /** HMAC secret for device signature verification */
942
+ /** HMAC secret for device signature verification (Phase 2a path) */
848
943
  deviceSecret: string;
849
944
  /** Allowed enrollment methods */
850
945
  allowedMethods?: EnrollmentMethod[];
@@ -856,6 +951,35 @@ export interface EnrollmentConfig {
856
951
  requireApproval?: boolean;
857
952
  /** Custom enrollment validation */
858
953
  validate?: (request: EnrollmentRequest) => Promise<boolean>;
954
+
955
+ /**
956
+ * Phase 2b device-pinned-key configuration. Optional — when
957
+ * omitted, enrollment continues to accept the Phase 2a HMAC path
958
+ * exclusively, matching pre-0.9 behaviour.
959
+ */
960
+ pinnedKey?: PinnedKeyConfig;
961
+ }
962
+
963
+ /**
964
+ * Device-pinned-key enrollment options. See
965
+ * `docs/concepts/enrollment` for the full flow.
966
+ */
967
+ export interface PinnedKeyConfig {
968
+ /**
969
+ * Require every new enrollment to use the pinned-key path. When
970
+ * `true`, requests without `publicKey` are rejected. When `false`
971
+ * (the default), both paths coexist during rollout — the server
972
+ * pins a public key when one is provided, falls back to HMAC when
973
+ * it isn't.
974
+ */
975
+ required?: boolean;
976
+
977
+ /**
978
+ * TTL for enrollment challenges, in seconds. Defaults to 300
979
+ * (5 minutes). Challenges are single-use; this only bounds how
980
+ * long an unused challenge stays valid.
981
+ */
982
+ challengeTtlSeconds?: number;
859
983
  }
860
984
 
861
985
  // ============================================
@@ -935,6 +1059,36 @@ export interface DatabaseAdapter {
935
1059
  moveGroup?(groupId: string, newParentId: string | null): Promise<Group>;
936
1060
  getGroupHierarchyStats?(): Promise<GroupHierarchyStats>;
937
1061
 
1062
+ // Enrollment challenges (optional - for Phase 2b device-pinned-key)
1063
+ /**
1064
+ * Persist a new single-use enrollment challenge. The adapter
1065
+ * should store it with `consumed_at = null` and enforce a
1066
+ * primary-key constraint on `challenge` so duplicate inserts
1067
+ * fail loudly.
1068
+ */
1069
+ createEnrollmentChallenge?(challenge: EnrollmentChallenge): Promise<void>;
1070
+ /**
1071
+ * Look up a challenge by its opaque value. Returns `null` if not
1072
+ * found. Does NOT filter on expiry — the core layer checks
1073
+ * freshness so the adapter stays dumb.
1074
+ */
1075
+ findEnrollmentChallenge?(challenge: string): Promise<EnrollmentChallenge | null>;
1076
+ /**
1077
+ * Atomically mark a challenge as consumed. Must set
1078
+ * `consumed_at = now()` and return the updated row only when the
1079
+ * challenge was previously unused. Adapters should implement
1080
+ * this as a conditional UPDATE (e.g. Postgres
1081
+ * `UPDATE ... WHERE consumed_at IS NULL RETURNING *`) so two
1082
+ * concurrent consume attempts cannot both succeed.
1083
+ */
1084
+ consumeEnrollmentChallenge?(challenge: string): Promise<EnrollmentChallenge | null>;
1085
+ /**
1086
+ * Delete expired, unconsumed challenges. Called periodically by
1087
+ * the core layer; adapters can no-op if they rely on a TTL index
1088
+ * elsewhere.
1089
+ */
1090
+ pruneExpiredEnrollmentChallenges?(now: Date): Promise<number>;
1091
+
938
1092
  // Tenants (optional - for multi-tenancy)
939
1093
  findTenant?(id: string): Promise<Tenant | null>;
940
1094
  findTenantBySlug?(slug: string): Promise<Tenant | null>;