feat(device-plane): add fail-closed deploy foundation
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/device-protocol-contract",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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 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,
|
||||
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 } : {}),
|
||||
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 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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
DEVICE_PLANE_BINDING_SCHEMA,
|
||||
assertIdentifierDigest,
|
||||
assertSafeProjection,
|
||||
hashRestrictedIdentifier,
|
||||
normalizeDevicePlaneBinding,
|
||||
normalizeDiscoverySignal,
|
||||
toSafeDiscoveryView,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const fakeImei = "000000000000001";
|
||||
const fakeSignal = {
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef: "session:test-001",
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
protocol: "INTERNAL",
|
||||
observedAt: "2026-07-25T00:00:00.000Z",
|
||||
identifier: {
|
||||
kind: "imei",
|
||||
value: fakeImei,
|
||||
},
|
||||
evidence: {
|
||||
transport: "tcp",
|
||||
bytesObserved: 128,
|
||||
framingStatus: "verified",
|
||||
specificationRef: "arusnavi.internal.framing.test-v1",
|
||||
},
|
||||
};
|
||||
|
||||
test("normalizes a verified discovery into quarantine with commands disabled", () => {
|
||||
const signal = normalizeDiscoverySignal(fakeSignal);
|
||||
assert.equal(signal.lifecycleState, "quarantine");
|
||||
assert.equal(signal.commandTransport, "disabled");
|
||||
assert.equal(signal.identifier.value, fakeImei);
|
||||
});
|
||||
|
||||
test("safe discovery projection masks the restricted identifier", () => {
|
||||
const view = toSafeDiscoveryView(fakeSignal, {
|
||||
discoveryRef: "discovery:test-001",
|
||||
});
|
||||
const serialized = JSON.stringify(view);
|
||||
assert.equal(view.identifier.masked, "***********0001");
|
||||
assert.equal(serialized.includes(fakeImei), false);
|
||||
assertSafeProjection(view);
|
||||
});
|
||||
|
||||
test("identifier hashing requires a strong process-only pepper", () => {
|
||||
const identifier = { kind: "imei", value: fakeImei };
|
||||
assert.throws(
|
||||
() => hashRestrictedIdentifier(identifier, "short"),
|
||||
/identifier_pepper_invalid/,
|
||||
);
|
||||
const digest = hashRestrictedIdentifier(
|
||||
identifier,
|
||||
"test-only-pepper-with-at-least-32-bytes",
|
||||
);
|
||||
assertIdentifierDigest(digest);
|
||||
assert.equal(digest.includes(fakeImei), false);
|
||||
assert.equal(
|
||||
digest,
|
||||
hashRestrictedIdentifier(
|
||||
identifier,
|
||||
"test-only-pepper-with-at-least-32-bytes",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unverified framing and command-shaped discovery input", () => {
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
...fakeSignal,
|
||||
evidence: { ...fakeSignal.evidence, framingStatus: "unverified" },
|
||||
}),
|
||||
/discovery_evidence_framing_unverified/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
...fakeSignal,
|
||||
command: { kind: "restart" },
|
||||
}),
|
||||
/forbidden_device_field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects secret-like fields recursively", () => {
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
...fakeSignal,
|
||||
metadata: { devicePassword: "not-a-real-password" },
|
||||
}),
|
||||
/forbidden_device_field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes an opaque Foundry control binding without device data", () => {
|
||||
const binding = normalizeDevicePlaneBinding({
|
||||
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
|
||||
bindingRef: "binding:test-001",
|
||||
contourRef: "contour:robot2b-test",
|
||||
capabilities: ["inspect", "observe", "observe"],
|
||||
});
|
||||
assert.deepEqual(binding.capabilities, ["inspect", "observe"]);
|
||||
assertSafeProjection(binding);
|
||||
});
|
||||
Reference in New Issue
Block a user