diff --git a/device-plane/deployment/device-edge-core-channel-source-v1.json b/device-plane/deployment/device-edge-core-channel-source-v1.json index 174890e..58b8dc2 100644 --- a/device-plane/deployment/device-edge-core-channel-source-v1.json +++ b/device-plane/deployment/device-edge-core-channel-source-v1.json @@ -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" }, diff --git a/device-plane/packages/device-edge-channel-contract/src/index.mjs b/device-plane/packages/device-edge-channel-contract/src/index.mjs index 16ab0a5..14fc3d2 100644 --- a/device-plane/packages/device-edge-channel-contract/src/index.mjs +++ b/device-plane/packages/device-edge-channel-contract/src/index.mjs @@ -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) { diff --git a/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs b/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs index 031083e..31dc530 100644 --- a/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs +++ b/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs @@ -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/); +}); diff --git a/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs b/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs index 02b171a..d9f80fc 100644 --- a/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs +++ b/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs @@ -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); diff --git a/device-plane/services/device-edge-channel/src/runtime.mjs b/device-plane/services/device-edge-channel/src/runtime.mjs index 53fe3d2..a75fef4 100644 --- a/device-plane/services/device-edge-channel/src/runtime.mjs +++ b/device-plane/services/device-edge-channel/src/runtime.mjs @@ -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, }); } diff --git a/device-plane/services/device-edge-channel/test/channel-integration.test.mjs b/device-plane/services/device-edge-channel/test/channel-integration.test.mjs index 49e82e3..2de6c24 100644 --- a/device-plane/services/device-edge-channel/test/channel-integration.test.mjs +++ b/device-plane/services/device-edge-channel/test/channel-integration.test.mjs @@ -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"), }; diff --git a/device-plane/services/device-gateway-core/src/runtime.mjs b/device-plane/services/device-gateway-core/src/runtime.mjs index c24092c..6373980 100644 --- a/device-plane/services/device-gateway-core/src/runtime.mjs +++ b/device-plane/services/device-gateway-core/src/runtime.mjs @@ -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, });