feat(device-plane): harden edge channel rotation
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user