import { createHmac } from "node:crypto"; export const DEVICE_DISCOVERY_SIGNAL_SCHEMA = "nodedc.device.discovery-signal.v1"; export const DEVICE_DISCOVERY_VIEW_SCHEMA = "nodedc.device.discovery-view.v1"; export const DEVICE_PLANE_BINDING_SCHEMA = "nodedc.device-plane-control.binding.v1"; export const DEVICE_LIFECYCLE_STATES = Object.freeze([ "quarantine", "claimed", "online", "offline", "retired", ]); export const DEVICE_BINDING_CAPABILITIES = Object.freeze([ "observe", "inspect", "configure", "command", ]); const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const IMEI_RE = /^\d{15}$/; const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/; const forbiddenKeyFragments = Object.freeze([ "password", "secret", "credential", "rawpayload", "rawpacket", "command", "authorization", "token", ]); const safeStatusKeys = new Set([ "commandtransport", ]); export function normalizeDiscoverySignal(input) { assertPlainObject(input, "discovery_signal"); rejectForbiddenKeys(input); if (input.schemaVersion !== DEVICE_DISCOVERY_SIGNAL_SCHEMA) { throw new TypeError("discovery_signal_schema_invalid"); } const sessionRef = normalizeOpaqueRef(input.sessionRef, "session_ref"); const routeRef = input.routeRef == null ? undefined : normalizeEntityRef(input.routeRef, "route", "route_ref"); const modelProfileRef = normalizeOpaqueRef( input.modelProfileRef, "model_profile_ref", ); const protocol = normalizeUpperToken(input.protocol, "protocol"); const observedAt = normalizeTimestamp(input.observedAt, "observed_at"); const identifier = normalizeRestrictedIdentifier(input.identifier); const evidence = normalizeDiscoveryEvidence(input.evidence); return Object.freeze({ schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, sessionRef, ...(routeRef ? { routeRef } : {}), modelProfileRef, protocol, observedAt, identifier, evidence, lifecycleState: "quarantine", commandTransport: "disabled", }); } export function toSafeDiscoveryView(signal, options = {}) { const normalized = normalizeDiscoverySignal(signal); const discoveryRef = options.discoveryRef ? normalizeOpaqueRef(options.discoveryRef, "discovery_ref") : undefined; return Object.freeze({ schemaVersion: DEVICE_DISCOVERY_VIEW_SCHEMA, ...(discoveryRef ? { discoveryRef } : {}), ...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}), modelProfileRef: normalized.modelProfileRef, protocol: normalized.protocol, observedAt: normalized.observedAt, lifecycleState: normalized.lifecycleState, identifier: Object.freeze({ kind: normalized.identifier.kind, masked: maskRestrictedIdentifier(normalized.identifier), }), evidence: normalized.evidence, commandTransport: "disabled", }); } export function hashRestrictedIdentifier(identifier, pepper) { const normalized = normalizeRestrictedIdentifier(identifier); if (typeof pepper !== "string" || pepper.length < 32) { throw new TypeError("identifier_pepper_invalid"); } const digest = createHmac("sha256", pepper) .update(`${normalized.kind}\0${normalized.value}`, "utf8") .digest("hex"); return `hmac-sha256:${digest}`; } export function assertIdentifierDigest(value) { if (typeof value !== "string" || !DIGEST_RE.test(value)) { throw new TypeError("identifier_digest_invalid"); } return value; } export function normalizeDevicePlaneBinding(input) { assertPlainObject(input, "device_plane_binding"); rejectForbiddenKeys(input); if (input.schemaVersion !== DEVICE_PLANE_BINDING_SCHEMA) { throw new TypeError("device_plane_binding_schema_invalid"); } const allowed = new Set(DEVICE_BINDING_CAPABILITIES); if (!Array.isArray(input.capabilities) || input.capabilities.length === 0) { throw new TypeError("device_plane_binding_capabilities_invalid"); } const capabilities = [...new Set(input.capabilities.map((value) => { if (typeof value !== "string" || !allowed.has(value)) { throw new TypeError("device_plane_binding_capability_invalid"); } return value; }))].sort(); return Object.freeze({ schemaVersion: DEVICE_PLANE_BINDING_SCHEMA, bindingRef: normalizeOpaqueRef(input.bindingRef, "binding_ref"), contourRef: normalizeOpaqueRef(input.contourRef, "contour_ref"), capabilities: Object.freeze(capabilities), }); } export function assertSafeProjection(value) { assertPlainObject(value, "safe_projection"); rejectForbiddenKeys(value); const serialized = JSON.stringify(value); if (/\b\d{15}\b/.test(serialized)) { throw new TypeError("safe_projection_contains_unmasked_imei"); } return value; } function normalizeRestrictedIdentifier(input) { assertPlainObject(input, "restricted_identifier"); if (input.kind !== "imei") { throw new TypeError("restricted_identifier_kind_unsupported"); } if (typeof input.value !== "string" || !IMEI_RE.test(input.value)) { throw new TypeError("restricted_identifier_imei_invalid"); } return Object.freeze({ kind: "imei", value: input.value }); } function maskRestrictedIdentifier(identifier) { if (identifier.kind === "imei") { return `***********${identifier.value.slice(-4)}`; } throw new TypeError("restricted_identifier_kind_unsupported"); } function normalizeDiscoveryEvidence(input) { assertPlainObject(input, "discovery_evidence"); rejectForbiddenKeys(input); if (input.transport !== "tcp") { throw new TypeError("discovery_evidence_transport_invalid"); } const bytesObserved = Number(input.bytesObserved); if (!Number.isSafeInteger(bytesObserved) || bytesObserved < 1 || bytesObserved > 4096) { throw new TypeError("discovery_evidence_bytes_invalid"); } if (input.framingStatus !== "verified") { throw new TypeError("discovery_evidence_framing_unverified"); } return Object.freeze({ transport: "tcp", bytesObserved, framingStatus: "verified", specificationRef: normalizeOpaqueRef( input.specificationRef, "framing_specification_ref", ), }); } function rejectForbiddenKeys(value, path = "$") { if (Array.isArray(value)) { value.forEach((item, index) => rejectForbiddenKeys(item, `${path}[${index}]`)); return; } if (!value || typeof value !== "object") return; for (const [key, child] of Object.entries(value)) { const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, ""); if ( !safeStatusKeys.has(normalizedKey) && forbiddenKeyFragments.some((fragment) => normalizedKey.includes(fragment)) ) { throw new TypeError(`forbidden_device_field:${path}.${key}`); } rejectForbiddenKeys(child, `${path}.${key}`); } } function normalizeOpaqueRef(value, label) { if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) { throw new TypeError(`${label}_invalid`); } return value; } function normalizeEntityRef(value, prefix, label) { if ( typeof value !== "string" || !new RegExp( `^${prefix}:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, "i", ).test(value) ) { throw new TypeError(`${label}_invalid`); } return value.toLowerCase(); } function normalizeUpperToken(value, label) { if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) { throw new TypeError(`${label}_invalid`); } return value; } function normalizeTimestamp(value, label) { if (typeof value !== "string") throw new TypeError(`${label}_invalid`); const date = new Date(value); if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) { throw new TypeError(`${label}_invalid`); } return value; } function assertPlainObject(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${label}_invalid`); } }