feat(device-plane): harden edge channel rotation

This commit is contained in:
Codex
2026-08-11 19:37:32 +03:00
parent 50b9179fe4
commit 1124c15216
7 changed files with 365 additions and 34 deletions
@@ -22,6 +22,8 @@
"edgeCertificatePinRequired": true,
"coreCertificateAllowlistRequired": true,
"unknownOrRevokedIdentity": "reject",
"rotation": "one-active-plus-one-staged-generation",
"retiredFingerprint": "reject",
"privateKeysInSource": false,
"privateKeysInArtifact": false
},
@@ -42,14 +44,17 @@
"delivery": "at-least-once-with-core-idempotency"
},
"sourceAcceptance": {
"devicePlaneTestsPassed": 189,
"devicePlaneTestsPassed": 193,
"tls13MutualAuthenticationTested": true,
"keepaliveTested": true,
"disconnectReconnectTested": true,
"certificateRotationOverlapAndRetirementTested": true,
"idempotentReplayTested": true,
"unknownAndRevokedIdentityTested": true,
"oversizedEnvelopeTested": true,
"coreUnavailableRejectionTested": true,
"crossSessionProgressAndPerSessionOrderingTested": true,
"boundedAcceptanceWindowTested": true,
"existingCoreImageBuild": "passed-no-cache",
"existingGatewayImageBuild": "passed-no-cache"
},
@@ -216,6 +216,45 @@ export function normalizeCertificateFingerprint(value) {
return compact.match(/.{2}/g).join(":");
}
export function normalizeCertificateIdentities(value) {
if (!Array.isArray(value) || value.length < 1 || value.length > 2) {
throw new TypeError("device_edge_channel_certificate_identities_invalid");
}
const generations = new Set();
const fingerprints = new Set();
let activeCount = 0;
const identities = value.map((identity) => {
assertPlainObject(identity, "device_edge_channel_certificate_identity");
rejectUnexpectedKeys(
identity,
new Set(["generationRef", "fingerprint", "status"]),
);
if (!["active", "staged"].includes(identity.status)) {
throw new TypeError("device_edge_channel_certificate_identity_status_invalid");
}
if (identity.status === "active") activeCount += 1;
const generationRef = normalizeRef(
identity.generationRef,
"certificate_generation_ref",
);
const fingerprint = normalizeCertificateFingerprint(identity.fingerprint);
if (generations.has(generationRef) || fingerprints.has(fingerprint)) {
throw new TypeError("device_edge_channel_certificate_identity_duplicate");
}
generations.add(generationRef);
fingerprints.add(fingerprint);
return Object.freeze({
generationRef,
fingerprint,
status: identity.status,
});
});
if (activeCount !== 1) {
throw new TypeError("device_edge_channel_active_certificate_identity_invalid");
}
return Object.freeze(identities);
}
export function nextReconnectDelay(attempt, options = {}) {
const normalizedAttempt = Number(attempt);
if (!Number.isSafeInteger(normalizedAttempt) || normalizedAttempt < 0) {
@@ -6,6 +6,7 @@ import {
createChannelEnvelopeDecoder,
encodeChannelEnvelope,
nextReconnectDelay,
normalizeCertificateIdentities,
normalizeChannelEnvelope,
} from "../src/index.mjs";
@@ -75,3 +76,31 @@ test("uses bounded jittered exponential reconnect delays", () => {
random: () => 0,
}), 15_000);
});
test("allows exactly one active and at most one staged certificate generation", () => {
const activeFingerprint = "AA:".repeat(31) + "AA";
const stagedFingerprint = "BB:".repeat(31) + "BB";
const identities = normalizeCertificateIdentities([
{
generationRef: "trust-generation:1",
fingerprint: activeFingerprint,
status: "active",
},
{
generationRef: "trust-generation:2",
fingerprint: stagedFingerprint,
status: "staged",
},
]);
assert.equal(identities.length, 2);
assert.equal(identities[0].status, "active");
assert.equal(identities[1].status, "staged");
assert.throws(() => normalizeCertificateIdentities([
{ ...identities[0], status: "staged" },
identities[1],
]), /active_certificate_identity_invalid/);
assert.throws(() => normalizeCertificateIdentities([
identities[0],
{ ...identities[1], status: "active" },
]), /active_certificate_identity_invalid/);
});
@@ -74,6 +74,11 @@ test("records source acceptance without opening an Edge or tracker port", async
assert.equal(acceptance.transport.tls, "TLSv1.3-mutual-authentication");
assert.equal(acceptance.identity.privateKeysInSource, false);
assert.equal(acceptance.identity.privateKeysInArtifact, false);
assert.equal(
acceptance.identity.rotation,
"one-active-plus-one-staged-generation",
);
assert.equal(acceptance.identity.retiredFingerprint, "reject");
assert.equal(acceptance.runtime.mutationInThisTransition, false);
assert.equal(acceptance.runtime.edgePort8443Published, false);
assert.equal(acceptance.runtime.trackerPort9921Published, false);
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { randomUUID, X509Certificate } from "node:crypto";
import { createSecureServer } from "node:http2";
import {
@@ -20,6 +20,7 @@ const CHANNEL_PROFILE_REF = "channel.control.v1";
export function createDeviceEdgeChannelServer(options = {}) {
const config = normalizeConfig(options);
let trust = config.trust;
const pending = new Map();
let active = null;
let started = false;
@@ -32,9 +33,9 @@ export function createDeviceEdgeChannelServer(options = {}) {
let totalProtocolFailures = 0;
const server = createSecureServer({
key: config.tls.key,
cert: config.tls.cert,
ca: config.tls.ca,
key: trust.key,
cert: trust.cert,
ca: trust.ca,
minVersion: "TLSv1.3",
maxVersion: "TLSv1.3",
allowHTTP1: false,
@@ -88,6 +89,7 @@ export function createDeviceEdgeChannelServer(options = {}) {
send(state, "channel.hello", {
status: "ready",
transport: "http2-mtls",
trustGeneration: trust.generationRef,
commandTransport: "disabled",
}, {
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
@@ -179,6 +181,8 @@ export function createDeviceEdgeChannelServer(options = {}) {
channel: active?.accepted ? "accepted" : active ? "negotiating" : "absent",
edgeRegistrationId: config.edgeRegistrationId,
channelGeneration: config.channelGeneration,
trustGeneration: trust.generationRef,
edgeCertificateFingerprint: trust.certificateFingerprint,
pendingAcceptances: pending.size,
channelsAccepted: totalChannelsAccepted,
channelsRejected: totalChannelsRejected,
@@ -190,6 +194,30 @@ export function createDeviceEdgeChannelServer(options = {}) {
commandTransport: "disabled",
});
},
rotateTrust(next) {
const nextTrust = normalizeTrust(next);
if (nextTrust.generationRef === trust.generationRef) {
throw new TypeError("device_edge_channel_trust_generation_unchanged");
}
server.setSecureContext({
key: nextTrust.key,
cert: nextTrust.cert,
ca: nextTrust.ca,
minVersion: "TLSv1.3",
maxVersion: "TLSv1.3",
});
trust = nextTrust;
const current = active;
if (current) {
current.stream.close();
closeActive(current);
}
return Object.freeze({
trustGeneration: trust.generationRef,
edgeCertificateFingerprint: trust.certificateFingerprint,
channel: "reconnect-required",
});
},
disconnectActiveChannel() {
active?.stream.close();
},
@@ -328,7 +356,7 @@ export function createDeviceEdgeChannelServer(options = {}) {
} catch {
return false;
}
return config.tls.allowedCoreFingerprints.has(fingerprint);
return trust.allowedCoreFingerprints.has(fingerprint);
}
function protocolFailure(state) {
@@ -368,7 +396,10 @@ function normalizeConfig(options) {
options.channelGeneration,
"channel_generation",
);
const tls = normalizeTls(options.tls);
const trust = normalizeTrust({
...options.tls,
generationRef: options.trustGeneration,
});
const keepaliveMs = normalizeDuration(options.keepaliveMs, 10,
DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs, "keepalive");
const deadPeerMs = normalizeDuration(options.deadPeerMs, keepaliveMs * 2,
@@ -379,7 +410,7 @@ function normalizeConfig(options) {
return Object.freeze({
edgeRegistrationId,
channelGeneration,
tls,
trust,
host: normalizeHost(options.host ?? "127.0.0.1"),
port: normalizePort(options.port ?? 8443),
keepaliveMs,
@@ -411,7 +442,7 @@ function normalizeConfig(options) {
});
}
function normalizeTls(value) {
function normalizeTrust(value) {
if (!value || typeof value !== "object") {
throw new TypeError("device_edge_channel_tls_invalid");
}
@@ -420,16 +451,38 @@ function normalizeTls(value) {
throw new TypeError(`device_edge_channel_tls_${key}_invalid`);
}
}
if (!Array.isArray(value.allowedCoreFingerprints) || value.allowedCoreFingerprints.length < 1) {
if (
!Array.isArray(value.allowedCoreFingerprints)
|| value.allowedCoreFingerprints.length < 1
|| value.allowedCoreFingerprints.length > 2
) {
throw new TypeError("device_edge_channel_core_identity_allowlist_invalid");
}
const generationRef = normalizeRef(
value.generationRef,
"trust_generation",
);
let certificateFingerprint;
try {
certificateFingerprint = normalizeCertificateFingerprint(
new X509Certificate(value.cert).fingerprint256,
);
} catch {
throw new TypeError("device_edge_channel_tls_cert_invalid");
}
const allowedCoreFingerprints = new Set(
value.allowedCoreFingerprints.map(normalizeCertificateFingerprint),
);
if (allowedCoreFingerprints.size !== value.allowedCoreFingerprints.length) {
throw new TypeError("device_edge_channel_core_identity_allowlist_duplicate");
}
return Object.freeze({
generationRef,
key: value.key,
cert: value.cert,
ca: value.ca,
allowedCoreFingerprints: new Set(
value.allowedCoreFingerprints.map(normalizeCertificateFingerprint),
),
certificateFingerprint,
allowedCoreFingerprints,
});
}
@@ -94,6 +94,66 @@ test("keeps the channel alive and reconnects without losing idempotency", async
}
});
test("rotates the Edge certificate through staged overlap and rejects retired identity", async () => {
const edge = createEdgeServer();
const address = await edge.start();
let registration = edgeRegistration(address, [
{
generationRef: "trust-generation:1",
fingerprint: certificates.edge.fingerprint,
status: "active",
},
{
generationRef: "trust-generation:2",
fingerprint: certificates.edgeNext.fingerprint,
status: "staged",
},
]);
const core = createCoreClient({
address,
registrationProvider: async () => registration,
});
try {
await core.start();
await core.waitForReady(2_000);
await waitFor(() => edge.status().channel === "accepted", 2_000);
assert.equal(core.status().edgeTrustGeneration, "trust-generation:1");
edge.rotateTrust({
generationRef: "trust-generation:2",
key: certificates.edgeNext.key,
cert: certificates.edgeNext.cert,
ca: certificates.ca,
allowedCoreFingerprints: [certificates.core.fingerprint],
});
await waitFor(() => core.status().connectionAttempts >= 2
&& core.status().channel === "accepted"
&& core.status().edgeTrustGeneration === "trust-generation:2"
&& edge.status().channel === "accepted", 2_000);
registration = edgeRegistration(address, [{
generationRef: "trust-generation:2",
fingerprint: certificates.edgeNext.fingerprint,
status: "active",
}]);
assert.equal((await edge.submitAdapterMessage(adapterMessage())).status, "accepted");
const failuresBeforeRollback = core.status().protocolFailures;
edge.rotateTrust({
generationRef: "trust-generation:3",
key: certificates.edge.key,
cert: certificates.edge.cert,
ca: certificates.ca,
allowedCoreFingerprints: [certificates.core.fingerprint],
});
await waitFor(() => core.status().protocolFailures > failuresBeforeRollback, 2_000);
assert.notEqual(core.status().channel, "accepted");
} finally {
await core.stop();
await edge.stop();
}
});
test("rejects a revoked Edge registration before opening a channel", async () => {
const edge = createEdgeServer();
const address = await edge.start();
@@ -167,6 +227,92 @@ test("returns a conclusive rejection when Core cannot accept a package", async (
}
});
test("isolates tracker session ordering while allowing cross-session progress", async () => {
let releaseSlow;
const slowGate = new Promise((resolve) => {
releaseSlow = resolve;
});
const calls = [];
const pair = await startPair({
acceptMessage: async (message) => {
calls.push(message.sessionRef);
if (message.sessionRef === "session:slow") await slowGate;
return acceptanceFor(message, false);
},
});
try {
let slowResolved = false;
const slow = pair.edge.submitAdapterMessage(adapterMessage({
sessionRef: "session:slow",
messageRef: "message:slow-1",
idempotencyKey: `sha256:${"c".repeat(64)}`,
})).then((value) => {
slowResolved = true;
return value;
});
await waitFor(() => calls.includes("session:slow"), 500);
const fast = await pair.edge.submitAdapterMessage(adapterMessage({
sessionRef: "session:fast",
messageRef: "message:fast-1",
idempotencyKey: `sha256:${"d".repeat(64)}`,
}));
assert.equal(fast.status, "accepted");
assert.equal(slowResolved, false);
releaseSlow();
assert.equal((await slow).status, "accepted");
} finally {
releaseSlow?.();
await stopPair(pair);
}
});
test("preserves per-session order and applies a bounded acceptance window", async () => {
let releaseFirst;
const firstGate = new Promise((resolve) => {
releaseFirst = resolve;
});
const calls = [];
const pair = await startPair({
maxPendingAcceptances: 2,
acceptMessage: async (message) => {
calls.push(message.messageRef);
if (message.messageRef === "message:ordered-1") await firstGate;
return acceptanceFor(message, false);
},
});
try {
const first = pair.edge.submitAdapterMessage(adapterMessage({
sessionRef: "session:ordered",
messageRef: "message:ordered-1",
idempotencyKey: `sha256:${"e".repeat(64)}`,
sequence: 1,
}));
await waitFor(() => calls.length === 1, 500);
const second = pair.edge.submitAdapterMessage(adapterMessage({
sessionRef: "session:ordered",
messageRef: "message:ordered-2",
idempotencyKey: `sha256:${"f".repeat(64)}`,
sequence: 2,
}));
await delay(30);
assert.deepEqual(calls, ["message:ordered-1"]);
await assert.rejects(
pair.edge.submitAdapterMessage(adapterMessage({
sessionRef: "session:overflow",
messageRef: "message:overflow-1",
idempotencyKey: `sha256:${"1".repeat(64)}`,
})),
/acceptance_window_full/,
);
releaseFirst();
await Promise.all([first, second]);
assert.deepEqual(calls, ["message:ordered-1", "message:ordered-2"]);
} finally {
releaseFirst?.();
await stopPair(pair);
}
});
test("closes the logical session on an unknown message kind", async () => {
const edge = createEdgeServer();
const address = await edge.start();
@@ -221,34 +367,41 @@ async function startPair(options = {}) {
}
function createEdgeServer(options = {}) {
const edgeCertificate = options.edgeCertificate ?? certificates.edge;
return createDeviceEdgeChannelServer({
edgeRegistrationId: "edge:pilot-1",
channelGeneration: "generation:pilot-1",
trustGeneration: options.edgeTrustGeneration ?? "trust-generation:1",
host: "127.0.0.1",
port: 0,
tls: {
key: certificates.edge.key,
cert: certificates.edge.cert,
key: edgeCertificate.key,
cert: edgeCertificate.cert,
ca: certificates.ca,
allowedCoreFingerprints: [certificates.core.fingerprint],
allowedCoreFingerprints: options.allowedCoreFingerprints
?? [certificates.core.fingerprint],
},
keepaliveMs: options.keepaliveMs ?? 50,
deadPeerMs: options.deadPeerMs ?? 150,
acceptanceTimeoutMs: 500,
maxPendingAcceptances: options.maxPendingAcceptances,
});
}
function createCoreClient(options) {
const clientCertificate = options.clientCertificate ?? certificates.core;
return createDeviceGatewayCoreChannelClient({
registration: {
edgeRegistrationId: "edge:pilot-1",
endpoint: `https://127.0.0.1:${options.address.port}/`,
servername: "localhost",
certificateFingerprint: options.expectedEdgeFingerprint
const registration = edgeRegistration(options.address,
options.edgeCertificateIdentities ?? [{
generationRef: "trust-generation:1",
fingerprint: options.expectedEdgeFingerprint
?? certificates.edge.fingerprint,
lifecycleState: options.lifecycleState ?? "active",
},
status: "active",
}],
options.lifecycleState ?? "active");
return createDeviceGatewayCoreChannelClient({
...(options.registrationProvider
? { registrationProvider: options.registrationProvider }
: { registration }),
tls: {
key: clientCertificate.key,
cert: clientCertificate.cert,
@@ -271,6 +424,16 @@ function createCoreClient(options) {
});
}
function edgeRegistration(address, certificateIdentities, lifecycleState = "active") {
return {
edgeRegistrationId: "edge:pilot-1",
endpoint: `https://127.0.0.1:${address.port}/`,
servername: "localhost",
certificateIdentities,
lifecycleState,
};
}
async function stopPair(pair) {
await pair.core.stop();
await pair.edge.stop();
@@ -293,7 +456,7 @@ function discoverySignal() {
};
}
function adapterMessage() {
function adapterMessage(overrides = {}) {
return {
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
edgeRef: "edge:pilot-1",
@@ -314,6 +477,7 @@ function adapterMessage() {
byteLength: 11,
packageDigest: `sha256:${"b".repeat(64)}`,
},
...overrides,
};
}
@@ -340,6 +504,10 @@ async function generateCertificateFixture(directory) {
"subjectAltName=DNS:localhost,IP:127.0.0.1",
"extendedKeyUsage=serverAuth",
]);
await issueCertificate(directory, "edge-next", "localhost", [
"subjectAltName=DNS:localhost,IP:127.0.0.1",
"extendedKeyUsage=serverAuth",
]);
await issueCertificate(directory, "core", "nodedc-device-gateway-core", [
"extendedKeyUsage=clientAuth",
]);
@@ -350,6 +518,7 @@ async function generateCertificateFixture(directory) {
return {
ca,
edge: await readCertificate(directory, "edge"),
edgeNext: await readCertificate(directory, "edge-next"),
core: await readCertificate(directory, "core"),
intruder: await readCertificate(directory, "intruder"),
};
@@ -8,6 +8,7 @@ import {
createChannelEnvelopeDecoder,
encodeChannelEnvelope,
nextReconnectDelay,
normalizeCertificateIdentities,
normalizeCertificateFingerprint,
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
import {
@@ -80,6 +81,10 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
channel: state?.ready ? "accepted" : state ? "connecting" : "absent",
edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null,
channelGeneration: state?.channelGeneration ?? null,
edgeTrustGeneration: state?.observedEdgeIdentity?.generationRef ?? null,
edgeCertificateFingerprint:
state?.observedEdgeIdentity?.fingerprint ?? null,
activeTrackerSessionChains: state?.sessionChains.size ?? 0,
connectionAttempts: totalConnectionAttempts,
channelsAccepted: totalChannelsAccepted,
reconnects: totalReconnects,
@@ -117,7 +122,9 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
direction: "edge-to-core",
maxEnvelopeBytes: config.maxEnvelopeBytes,
}),
sessionChains: new Map(),
channelGeneration: null,
observedEdgeIdentity: null,
edgeSequence: 0,
coreSequence: 0,
lastEdgeActivityAt: config.clock(),
@@ -220,6 +227,8 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
if (
envelope.payload?.status !== "ready"
|| envelope.payload?.transport !== "http2-mtls"
|| envelope.payload?.trustGeneration
!== connection.observedEdgeIdentity?.generationRef
|| envelope.payload?.commandTransport !== "disabled"
) {
throw new Error("device_gateway_core_channel_hello_invalid");
@@ -242,17 +251,34 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
return;
}
if (envelope.messageKind === "channel.heartbeat") return;
if (envelope.messageKind === "discovery.observed") {
await acceptDiscovery(connection, envelope);
return;
}
if (envelope.messageKind === "adapter.message") {
await acceptAdapterMessage(connection, envelope);
if (["discovery.observed", "adapter.message"].includes(envelope.messageKind)) {
scheduleTrackerEvent(connection, envelope);
return;
}
throw new Error("device_gateway_core_edge_message_unhandled");
}
function scheduleTrackerEvent(connection, envelope) {
if (envelope.trackerSessionId === CHANNEL_TRACKER_SESSION_ID) {
throw new Error("device_gateway_core_tracker_session_invalid");
}
const previous = connection.sessionChains.get(envelope.trackerSessionId);
if (!previous && connection.sessionChains.size >= 128) {
throw new Error("device_gateway_core_tracker_session_limit_reached");
}
const work = (previous ?? Promise.resolve())
.then(() => envelope.messageKind === "discovery.observed"
? acceptDiscovery(connection, envelope)
: acceptAdapterMessage(connection, envelope))
.catch((error) => failConnection(connection, error))
.finally(() => {
if (connection.sessionChains.get(envelope.trackerSessionId) === work) {
connection.sessionChains.delete(envelope.trackerSessionId);
}
});
connection.sessionChains.set(envelope.trackerSessionId, work);
}
async function acceptDiscovery(connection, envelope) {
try {
const signal = normalizeDiscoverySignal(envelope.payload?.signal);
@@ -352,9 +378,13 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
const observed = normalizeCertificateFingerprint(
socket.getPeerCertificate()?.fingerprint256,
);
if (observed !== connection.registration.certificateFingerprint) {
const identity = connection.registration.certificateIdentities.find(
(candidate) => candidate.fingerprint === observed,
);
if (!identity) {
throw new Error("device_gateway_core_edge_identity_mismatch");
}
connection.observedEdgeIdentity = identity;
}
function failConnection(connection, error) {
@@ -369,6 +399,7 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
connection.closed = true;
clearInterval(connection.heartbeatTimer);
connection.heartbeatTimer = null;
connection.sessionChains.clear();
try {
connection.request?.close();
} catch {}
@@ -520,8 +551,8 @@ function normalizeRegistration(value) {
),
endpoint: endpoint.toString(),
servername,
certificateFingerprint: normalizeCertificateFingerprint(
value.certificateFingerprint,
certificateIdentities: normalizeCertificateIdentities(
value.certificateIdentities,
),
lifecycleState: value.lifecycleState,
});