From 6a8c1ce1efaf5f996b12b07679aa1dc45962baea Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 12:03:21 +0300 Subject: [PATCH] feat(device-core): protect enrollment identifiers --- .../device-protocol-contract/src/index.mjs | 9 +- .../services/device-control-core/src/app.mjs | 42 ++++++- .../src/infrastructure-repository.mjs | 57 ++++++++++ .../device-control-core/src/server.mjs | 2 +- .../device-control-core/test/app.test.mjs | 13 ++- .../test/infrastructure-app.test.mjs | 104 +++++++++++++++++- ...infrastructure-lifecycle-contract.test.mjs | 19 ++++ 7 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs diff --git a/device-plane/packages/device-protocol-contract/src/index.mjs b/device-plane/packages/device-protocol-contract/src/index.mjs index b243b47..2f5cca9 100644 --- a/device-plane/packages/device-protocol-contract/src/index.mjs +++ b/device-plane/packages/device-protocol-contract/src/index.mjs @@ -202,7 +202,7 @@ export function assertSafeProjection(value) { return value; } -function normalizeRestrictedIdentifier(input) { +export function normalizeRestrictedIdentifier(input) { assertPlainObject(input, "restricted_identifier"); if (input.kind !== "imei") { throw new TypeError("restricted_identifier_kind_unsupported"); @@ -213,9 +213,10 @@ function normalizeRestrictedIdentifier(input) { return Object.freeze({ kind: "imei", value: input.value }); } -function maskRestrictedIdentifier(identifier) { - if (identifier.kind === "imei") { - return `***********${identifier.value.slice(-4)}`; +export function maskRestrictedIdentifier(identifier) { + const normalized = normalizeRestrictedIdentifier(identifier); + if (normalized.kind === "imei") { + return `***********${normalized.value.slice(-4)}`; } throw new TypeError("restricted_identifier_kind_unsupported"); } diff --git a/device-plane/services/device-control-core/src/app.mjs b/device-plane/services/device-control-core/src/app.mjs index eaec83b..9c3efcd 100644 --- a/device-plane/services/device-control-core/src/app.mjs +++ b/device-plane/services/device-control-core/src/app.mjs @@ -4,7 +4,9 @@ import { createServer } from "node:http"; import { assertSafeProjection, hashRestrictedIdentifier, + maskRestrictedIdentifier, normalizeDiscoverySignal, + normalizeRestrictedIdentifier, toSafeDiscoveryView, } from "../../../packages/device-protocol-contract/src/index.mjs"; import { @@ -76,6 +78,9 @@ export function createControlCoreApp({ if (typeof managementToken !== "string" || managementToken.length < 32) { throw new TypeError("device_management_token_invalid"); } + if (typeof identifierPepper !== "string" || identifierPepper.length < 32) { + throw new TypeError("device_identifier_pepper_invalid"); + } } const server = createServer(async (request, response) => { @@ -121,9 +126,12 @@ export function createControlCoreApp({ ); const actor = managementActorFromHeaders(request.headers); const input = await readJsonBody(request, 64 * 1024); + const protectedInput = managementCommandKind === "enrollment_intent.ensure" + ? protectEnrollmentIdentifier(input, identifierPepper) + : input; const command = normalizeDeviceManagementCommand( managementCommandKind, - input, + protectedInput, ); const requestDigest = managementRequestDigest({ actor, @@ -263,6 +271,38 @@ export function createControlCoreApp({ return server; } +function protectEnrollmentIdentifier(input, identifierPepper) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new TypeError("device_enrollment_input_invalid"); + } + const allowedKeys = new Set([ + "projectRef", + "enrollmentKey", + "routeRef", + "modelProfileRef", + "displayName", + "identifier", + "expiresAt", + ]); + for (const key of Object.keys(input)) { + if (!allowedKeys.has(key)) { + throw new TypeError("device_enrollment_input_field_unexpected"); + } + } + const identifier = normalizeRestrictedIdentifier(input.identifier); + return Object.freeze({ + projectRef: input.projectRef, + enrollmentKey: input.enrollmentKey, + routeRef: input.routeRef, + modelProfileRef: input.modelProfileRef, + displayName: input.displayName, + identifierKind: identifier.kind, + identifierDigest: hashRestrictedIdentifier(identifier, identifierPepper), + identifierMasked: maskRestrictedIdentifier(identifier), + expiresAt: input.expiresAt, + }); +} + function projectWorkspaceId(pathname) { const match = pathname.match( /^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/workspace$/i, diff --git a/device-plane/services/device-control-core/src/infrastructure-repository.mjs b/device-plane/services/device-control-core/src/infrastructure-repository.mjs index 73825a9..29405ed 100644 --- a/device-plane/services/device-control-core/src/infrastructure-repository.mjs +++ b/device-plane/services/device-control-core/src/infrastructure-repository.mjs @@ -73,6 +73,13 @@ async function ensureAdapterPackage(client, actor, command) { lifecycle_state = excluded.lifecycle_state, updated_at = now() where device_adapter_packages.publisher_ref = excluded.publisher_ref + and ( + device_adapter_packages.lifecycle_state = excluded.lifecycle_state + or ( + device_adapter_packages.lifecycle_state = 'active' + and excluded.lifecycle_state = 'retired' + ) + ) returning id, package_key, display_name, publisher_ref, lifecycle_state, created_at, updated_at, (xmax = 0) as created`, [ @@ -135,6 +142,17 @@ async function registerAdapterVersion(client, actor, command) { and device_adapter_versions.content_digest = excluded.content_digest and device_adapter_versions.contract_version = excluded.contract_version and device_adapter_versions.capabilities = excluded.capabilities + and ( + device_adapter_versions.lifecycle_state = excluded.lifecycle_state + or ( + device_adapter_versions.lifecycle_state = 'draft' + and excluded.lifecycle_state in ('active', 'retired') + ) + or ( + device_adapter_versions.lifecycle_state = 'active' + and excluded.lifecycle_state = 'retired' + ) + ) returning id, adapter_package_id, version, runtime_package_ref, content_digest, contract_version, capabilities, lifecycle_state, created_at, updated_at, (xmax = 0) as created`, @@ -231,6 +249,17 @@ async function registerModelProfile(client, actor, command) { and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref and device_model_profiles.profile_digest = excluded.profile_digest and device_model_profiles.capabilities = excluded.capabilities + and ( + device_model_profiles.lifecycle_state = excluded.lifecycle_state + or ( + device_model_profiles.lifecycle_state = 'draft' + and excluded.lifecycle_state in ('active', 'retired') + ) + or ( + device_model_profiles.lifecycle_state = 'active' + and excluded.lifecycle_state = 'retired' + ) + ) returning profile_ref, schema_version, vendor, model, device_type, protocol, adapter_version_id, schema_artifact_ref, profile_digest, capabilities, lifecycle_state, created_at, updated_at, @@ -289,6 +318,20 @@ async function ensureEdge(client, actor, command) { deployment_ref = excluded.deployment_ref, lifecycle_state = excluded.lifecycle_state, updated_at = now() + where + device_edges.lifecycle_state = excluded.lifecycle_state + or ( + device_edges.lifecycle_state = 'provisioning' + and excluded.lifecycle_state in ('active', 'retired') + ) + or ( + device_edges.lifecycle_state = 'active' + and excluded.lifecycle_state in ('suspended', 'retired') + ) + or ( + device_edges.lifecycle_state = 'suspended' + and excluded.lifecycle_state in ('active', 'retired') + ) returning id, edge_key, display_name, deployment_ref, lifecycle_state, created_at, updated_at, (xmax = 0) as created`, [ @@ -351,6 +394,20 @@ async function ensureRoute(client, actor, command) { direction = excluded.direction, lifecycle_state = excluded.lifecycle_state, updated_at = now() + where + device_routes.lifecycle_state = excluded.lifecycle_state + or ( + device_routes.lifecycle_state = 'draft' + and excluded.lifecycle_state in ('active', 'retired') + ) + or ( + device_routes.lifecycle_state = 'active' + and excluded.lifecycle_state in ('suspended', 'retired') + ) + or ( + device_routes.lifecycle_state = 'suspended' + and excluded.lifecycle_state in ('active', 'retired') + ) returning id, project_id, route_key, display_name, edge_id, model_profile_ref, listener_ref, protocol, direction, lifecycle_state, created_at, updated_at, (xmax = 0) as created`, diff --git a/device-plane/services/device-control-core/src/server.mjs b/device-plane/services/device-control-core/src/server.mjs index ae9e261..0c9d50b 100644 --- a/device-plane/services/device-control-core/src/server.mjs +++ b/device-plane/services/device-control-core/src/server.mjs @@ -67,7 +67,7 @@ async function readConfig() { "device_gateway_core_token_file_required", ) : "", - identifierPepper: discoveryIngestEnabled + identifierPepper: discoveryIngestEnabled || managementApiEnabled ? await readRequiredSecretFile( process.env.DEVICE_IDENTIFIER_PEPPER_FILE, "device_identifier_pepper_file_required", diff --git a/device-plane/services/device-control-core/test/app.test.mjs b/device-plane/services/device-control-core/test/app.test.mjs index 9e59e03..39bd90f 100644 --- a/device-plane/services/device-control-core/test/app.test.mjs +++ b/device-plane/services/device-control-core/test/app.test.mjs @@ -75,6 +75,17 @@ test("management API cannot start without its repository boundary and strong tok }), /device_management_token_invalid/, ); + assert.throws( + () => createControlCoreApp({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async () => ({}), + }, + }), + /device_identifier_pepper_invalid/, + ); }); test("management API requires service auth and an idempotency key", async () => { @@ -427,7 +438,7 @@ function ownerScopeCommand() { } async function startTestServer(options) { - const server = createControlCoreApp(options); + const server = createControlCoreApp({ identifierPepper, ...options }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); diff --git a/device-plane/services/device-control-core/test/infrastructure-app.test.mjs b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs index b3d104f..717b724 100644 --- a/device-plane/services/device-control-core/test/infrastructure-app.test.mjs +++ b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { createControlCoreApp } from "../src/app.mjs"; const managementToken = "test-only-management-token-with-32-bytes"; +const identifierPepper = "test-only-identifier-pepper-with-32-bytes"; test("management API forwards a normalized generic Edge registration", async () => { let executed; @@ -116,6 +117,107 @@ test("management API forwards claim as evidence references without identity inpu } }); +test("management API derives enrollment identity inside Core and never forwards raw IMEI", async () => { + let executed; + const runtime = await startServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async (input) => { + executed = input; + return { + replayed: false, + result: { + enrollmentIntent: { + identifier: { + kind: input.command.identifierKind, + masked: input.command.identifierMasked, + }, + }, + }, + }; + }, + }, + }); + try { + const rawImei = "123456789012345"; + const response = await fetch( + `${runtime.baseUrl}/internal/v1/management/enrollment-intents:ensure`, + { + method: "POST", + headers: { + ...managementHeaders(), + "Idempotency-Key": "phase25-enrollment-0001", + }, + body: JSON.stringify({ + projectRef: "project:11111111-1111-4111-8111-111111111111", + enrollmentKey: "pilot-device", + routeRef: "route:22222222-2222-4222-8222-222222222222", + modelProfileRef: "arusnavi.b2.v1", + displayName: "Pilot device", + identifier: { kind: "imei", value: rawImei }, + expiresAt: null, + }), + }, + ); + + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(executed.command.identifierKind, "imei"); + assert.equal(executed.command.identifierMasked, "***********2345"); + assert.match(executed.command.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); + assert.equal(JSON.stringify(executed).includes(rawImei), false); + assert.equal(JSON.stringify(body).includes(rawImei), false); + } finally { + await runtime.close(); + } +}); + +test("management API rejects client-supplied enrollment digests", async () => { + let executions = 0; + const runtime = await startServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async () => { + executions += 1; + return { replayed: false, result: {} }; + }, + }, + }); + try { + const response = await fetch( + `${runtime.baseUrl}/internal/v1/management/enrollment-intents:ensure`, + { + method: "POST", + headers: { + ...managementHeaders(), + "Idempotency-Key": "phase25-enrollment-reject-0001", + }, + body: JSON.stringify({ + projectRef: "project:11111111-1111-4111-8111-111111111111", + enrollmentKey: "pilot-device", + routeRef: "route:22222222-2222-4222-8222-222222222222", + modelProfileRef: "arusnavi.b2.v1", + displayName: "Pilot device", + identifier: { kind: "imei", value: "123456789012345" }, + identifierDigest: `hmac-sha256:${"a".repeat(64)}`, + }), + }, + ); + assert.equal(response.status, 400); + assert.equal( + (await response.json()).error, + "device_enrollment_input_field_unexpected", + ); + assert.equal(executions, 0); + } finally { + await runtime.close(); + } +}); + test("management API accepts only a canonical credential reference", async () => { let executed; const runtime = await startServer({ @@ -195,7 +297,7 @@ test("management API accepts only a canonical credential reference", async () => }); async function startServer(options) { - const server = createControlCoreApp(options); + const server = createControlCoreApp({ identifierPepper, ...options }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); diff --git a/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs b/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs new file mode 100644 index 0000000..3afa4a1 --- /dev/null +++ b/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const repositorySource = new URL( + "../src/infrastructure-repository.mjs", + import.meta.url, +); + +test("catalog, Edge and route upserts enforce irreversible lifecycle transitions", async () => { + const source = await readFile(repositorySource, "utf8"); + + assert.match(source, /device_adapter_versions\.lifecycle_state = 'draft'[\s\S]*excluded\.lifecycle_state in \('active', 'retired'\)/); + assert.match(source, /device_model_profiles\.lifecycle_state = 'active'[\s\S]*excluded\.lifecycle_state = 'retired'/); + assert.match(source, /device_edges\.lifecycle_state = 'suspended'[\s\S]*excluded\.lifecycle_state in \('active', 'retired'\)/); + assert.match(source, /device_routes\.lifecycle_state = 'draft'[\s\S]*excluded\.lifecycle_state in \('active', 'retired'\)/); + assert.doesNotMatch(source, /device_(?:adapter_versions|model_profiles|edges|routes)\.lifecycle_state = 'retired'[\s\S]{0,160}excluded\.lifecycle_state = 'active'/); +}); +