Phase 2b: Device-Pinned-Key Rollout
Tracks what has shipped in @openmdm/core for device-pinned-key enrollment and the Android-side work required to adopt it. Not a design spec — the server side is already implemented.
Phase 2b: Device-Pinned-Key Rollout
Status. The server-side primitives shipped in
@openmdm/coreand@openmdm/hono. This document tracks the remaining work inopenmdm-android(and any agent fork) required to turn the new path on in production. It is a rollout tracker, not a design spec — the design is already encoded inconcepts/enrollmentand the source underpackages/core/src/device-identity.ts.
What shipped on the server
@openmdm/core:device-identity.tsmodule —importPublicKeyFromSpki,verifyEcdsaSignature,canonicalEnrollmentMessage,canonicalDeviceRequestMessage,verifyDeviceRequest. Zero dependencies; uses Node's built-innode:crypto.- New error types:
InvalidPublicKeyError,PublicKeyMismatchError,ChallengeInvalidError. EnrollmentRequestgains optionalpublicKeyandattestationChallengefields.DevicegainspublicKeyandenrollmentMethodfields.EnrollmentConfiggains apinnedKeyblock for opt-in enforcement.DatabaseAdaptergains four optional methods for challenge storage:createEnrollmentChallenge,findEnrollmentChallenge,consumeEnrollmentChallenge,pruneExpiredEnrollmentChallenges.mdm.enroll()branches onpublicKeypresence and enforces continuity on re-enrollment.
@openmdm/drizzle-adapter:- New
mdm_enrollment_challengestable and newpublic_key+enrollment_methodcolumns onmdm_devicesin the Postgres schema. - Adapter methods implemented, including an atomic
UPDATE ... WHERE consumed_at IS NULL RETURNING *consume path. - Full e2e test coverage against real Postgres, including the concurrent-consume race invariant.
- New
@openmdm/hono:- New
GET /agent/enroll/challengeroute. Returns 503 when the underlying adapter does not support challenge storage.
- New
This is enough to accept enrollments on the new path. No device can actually use the new path yet, because the Android agent has not been updated to generate a Keystore keypair and sign with it. That's the rest of this document.
What is still needed on the agent side
These are the concrete changes to openmdm-android (and to any fork of it, including midiamob-solution/apps/mobile/openmdm-android) required before the pinned-key path can be turned on for real devices.
1. Generate an ECDSA P-256 keypair in the Keystore on first enrollment
// agent/src/main/java/com/openmdm/agent/security/DeviceIdentity.kt
object DeviceIdentity {
private const val ALIAS = "openmdm_device_identity"
private const val KEYSTORE = "AndroidKeyStore"
fun getOrCreateKeyPair(): KeyPair {
val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
if (ks.containsAlias(ALIAS)) {
val privateKey = ks.getKey(ALIAS, null) as java.security.PrivateKey
val publicKey = ks.getCertificate(ALIAS).publicKey
return KeyPair(publicKey, privateKey)
}
val spec = KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(false) // device identity, not a user credential
// StrongBox if the device supports it; fall back transparently.
.apply {
if (Build.VERSION.SDK_INT >= 28 &&
context.packageManager.hasSystemFeature(
PackageManager.FEATURE_STRONGBOX_KEYSTORE,
)
) {
setIsStrongBoxBacked(true)
}
}
.build()
val generator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC,
KEYSTORE,
)
generator.initialize(spec)
return generator.generateKeyPair()
}
fun publicKeySpkiBase64(): String =
Base64.encodeToString(getOrCreateKeyPair().public.encoded, Base64.NO_WRAP)
}The key is created once, persists in the Keystore across app reinstalls (unless the user explicitly clears app data or factory-resets), and never leaves the device's secure container. On ZK-R32D and similar vendor boards that lack StrongBox, the setIsStrongBoxBacked call is skipped and the key lives in the software Keystore — still sandboxed per-app, still better than a shared HMAC secret, still compatible with every check the server does.
2. Fetch a challenge before enrollment
val challengeResponse = api.fetchEnrollmentChallenge() // GET /agent/enroll/challenge
val challenge = challengeResponse.challenge
val expiresAt = challengeResponse.expiresAt3. Sign the canonical enrollment message and POST
val publicKey = DeviceIdentity.publicKeySpkiBase64()
val canonical = listOf(
publicKey,
deviceInfo.model,
deviceInfo.manufacturer,
deviceInfo.osVersion,
deviceInfo.serialNumber.orEmpty(),
deviceInfo.imei.orEmpty(),
deviceInfo.macAddress.orEmpty(),
deviceInfo.androidId.orEmpty(),
method,
timestamp,
challenge,
).joinToString("|")
val signer = Signature.getInstance("SHA256withECDSA").apply {
initSign(DeviceIdentity.getOrCreateKeyPair().private)
update(canonical.toByteArray(Charsets.UTF_8))
}
val signatureBase64 = Base64.encodeToString(signer.sign(), Base64.NO_WRAP)
api.enroll(EnrollmentRequest(
// ...existing fields
publicKey = publicKey,
attestationChallenge = challenge,
signature = signatureBase64,
))The canonical form must match canonicalEnrollmentMessage(...) in @openmdm/core exactly. A single byte drift and the server rejects every enrollment. The server-side contract test at packages/core/tests/device-identity.test.ts pins the current shape; the Android side should have a mirror test that builds the same message and compares strings.
4. Gap: the agent's current canonical HMAC form is broken
openmdm-android/agent/src/main/java/com/openmdm/agent/util/SignatureGenerator.kt:25 uses "{identifier}:{timestamp}" as the HMAC canonical form. This is the old broken form from before openmdm PR #9 fixed it. The current agent cannot successfully enroll against a current openmdm server using the HMAC path. Fix this first — it's blocking all enrollment today, not just the new path. The correct form is documented in packages/core/src/index.ts:verifyEnrollmentSignature.
5. Gap: the agent does not send X-Openmdm-Protocol: 2
The envelope protocol exists specifically to prevent the auto-unenroll bug that transient 401/404 responses used to cause. The agent never opts into it, so it still interprets raw HTTPException responses and is vulnerable to the original bug. Add:
// In the OkHttp client builder, next to interceptors.
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.addHeader("X-Openmdm-Protocol", "2")
.build()
)
}And on the agent side, handle the AgentResponse<T> envelope explicitly — action: 'reauth' refreshes the token, action: 'unenroll' triggers deliberate re-enrollment, action: 'retry' backs off. See concepts/agent-protocol.
6. Gap: no TLS certificate pinning
openmdm-android's OkHttp client has no CertificatePinner. A MITM on the network during the first enrollment can substitute their own server TLS certificate, handshake the agent, and receive the device's public key as a client pinning itself against the attacker's server instead of the real one. After that, the attacker owns the device's identity forever.
Device-pinned-key enrollment is not secure without TLS pinning. Either:
- Pin the server's public key via OkHttp's
CertificatePinnerwith the expected SPKI hash, rotated via the existing APK release cadence. - Or pin the issuer (Let's Encrypt ISRG X1, GoDaddy, whatever) via a custom
X509TrustManagerthat accepts only certs under that issuer for the known server hostname.
Pinning must land in the Android repo in the same release that enables device-pinned-key enrollment. If it doesn't, the feature is a net security regression — the HMAC path at least has a shared secret an attacker needs, while the pinned-key path without TLS pinning lets an attacker silently bootstrap their own identity.
7. Hardware feature detection
Add a pre-flight check so the agent can fall back gracefully on ancient devices:
val hasKeystore = context.packageManager.hasSystemFeature(
PackageManager.FEATURE_HARDWARE_KEYSTORE,
)
// If false, the device has no hardware Keymaster at all. The
// software Keystore will still produce a usable key, but operators
// who want a hardware-backed guarantee should reject the device.minSdk 26 is fine for the ECDSA P-256 + software Keystore path. StrongBox requires API 28+, so the setIsStrongBoxBacked call needs a version gate.
Rollout order
Do these in order. Each step is independently shippable and each depends on the previous.
- Fix
SignatureGenerator.ktto use the correct nine-field HMAC canonical form. This unblocks enrollment against the current openmdm server. No new features — just fixing a pre-existing bug. - Add
X-Openmdm-Protocol: 2header and handle envelope responses end-to-end. Makes the agent match the server's current wire protocol. - Add OkHttp
CertificatePinnerwith the production server's SPKI hash. This is the precondition for step 4. - Generate EC P-256 keypair in the Keystore on first run, add the
fetchEnrollmentChallenge()→signCanonical()flow, submit the new enrollment shape. - Let the fleet drain onto the new path for a month or two while
enrollment.pinnedKey.requiredstaysfalseon the server. - Flip
required: trueonce metrics show every device has re-enrolled on the pinned-key path.
Cross-service reuse
The most interesting consequence of pinning a public key on the device row is that the same identity becomes usable from services that aren't openmdm at all. Any backend that imports @openmdm/core can call verifyDeviceRequest({ mdm, deviceId, canonicalMessage, signatureBase64 }) and get a pass/fail against the same pinned key. See the "Reusing the same identity outside OpenMDM" section of the enrollment page.
For midiamob specifically: the deviceValidation.ts middleware currently verifies an HMAC against a shared DEVICE_HMAC_SECRET and looks up the device by pairing code. The migration to device-pinned-key auth is a one-file change:
import { verifyDeviceRequest, canonicalDeviceRequestMessage } from '@openmdm/core';
// middleware body
const canonical = canonicalDeviceRequestMessage({
deviceId: pairingCode,
timestamp,
body,
});
const result = await verifyDeviceRequest({
mdm,
deviceId: pairingCode, // or whatever maps to Device.id
canonicalMessage: canonical,
signatureBase64: signature,
});
if (!result.ok) {
if (result.reason === 'no-pinned-key') {
// Legacy device, still on HMAC. Fall back to the old path.
return legacyHmacVerify(c, next);
}
return c.json({ error: result.reason }, 401);
}The player app signs its requests the same way the agent would: using the device's Keystore private key exposed via the agent's MDMContentProvider (or a sibling signRequest(nonce) ContentProvider method if you want to avoid exposing the private key material even to other apps on the same device). That interface is a midiamob-side design decision, not an openmdm one.
References
/docs/concepts/enrollment— the full server-side contract for both paths.packages/core/src/device-identity.ts— the crypto primitives.packages/core/tests/device-identity.test.ts— the contract tests that pin the canonical form.tests/e2e/tests/enrollment-challenge.e2e.test.ts— the Postgres-backed round-trip test.