258 lines
7.7 KiB
JavaScript
258 lines
7.7 KiB
JavaScript
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`);
|
|
}
|
|
}
|