From d7c94dccf902c8ab60d08e25e8431bcb925774d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Tue, 23 Jun 2026 23:18:35 +0300 Subject: [PATCH] Fix embedded OAuth and provisioning caCert for TLS_PATH_* listener certs Centralize listener TLS in tls-config.js. Embedded OAuth skips outbound discovery and trusts listener CA material for token exchange. Provisioning-key and GET /api/v3/agent/cert return base64 caCert from the same TLS config, fixing empty caCert when only TLS_PATH_* / TLS_BASE64_* were set. --- CHANGELOG.md | 2 + docs/swagger.yaml | 3 + src/config/embedded-oidc.js | 30 +++++- src/config/oidc-fetch.js | 37 +++++++ src/config/oidc.js | 34 +++++-- src/server.js | 28 ++---- src/services/agent-service.js | 47 ++------- src/services/iofog-service.js | 45 +-------- src/utils/tls-config.js | 104 +++++++++++++++++++ test/src/config/oidc.test.js | 81 +++++++++++++-- test/src/services/agent-service.test.js | 54 ++++++++++ test/src/services/iofog-service.test.js | 66 ++++++++++++ test/src/utils/tls-config.test.js | 128 ++++++++++++++++++++++++ test/tls-cert/ca.crt | 20 ++++ test/tls-cert/tls.crt | 20 ++++ test/tls-cert/tls.key | 27 +++++ 16 files changed, 609 insertions(+), 117 deletions(-) create mode 100644 src/config/oidc-fetch.js create mode 100644 src/utils/tls-config.js create mode 100644 test/src/utils/tls-config.test.js create mode 100644 test/tls-cert/ca.crt create mode 100644 test/tls-cert/tls.crt create mode 100644 test/tls-cert/tls.key diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b7e02c..f96f699a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,8 @@ Controller v3.8 is a **greenfield** release aligned with **Edgelet**. There is * - Central local CAs (`default-router-local-ca`, `default-nats-local-ca`) are ensured on first agent provision (or via operator direct import before first agent), not at Controller boot — allows custom local CAs before agent deployment. - Fog teardown drops obsolete per-fog **`nats-local-ca-*`** and **`router-local-ca-*`** secret names from cleanup lists. - OIDC discovery with **`AUTH_INSECURE_ALLOW_HTTP`** uses the supported `openid-client` insecure-request hook for local **`http://`** issuers. +- Embedded OAuth BFF builds the in-process issuer client from local metadata and trusts listener TLS material (**`TLS_PATH_*`** / **`TLS_BASE64_*`**) for token exchange — fixes **`fetch failed`** on **`GET /api/v3/user/oauth/authorize`** with self-signed HTTPS certs without **`NODE_EXTRA_CA_CERTS`**. +- Provisioning key and **`GET /api/v3/agent/cert`** derive **`caCert`** from listener TLS material (**`TLS_PATH_*`** / **`TLS_BASE64_*`**) via shared **`tls-config`** — always base64-encoded for Edgelet trust store; fixes empty **`caCert`** when legacy **`SSL_CERT`** / **`INTERMEDIATE_CERT`** were unset. - Config keys **`auth.bootstrap.adminUsername`** / **`adminPassword`** renamed to **`auth.bootstrap.username`** / **`password`** (**`OIDC_BOOTSTRAP_ADMIN_*`** env vars unchanged). - Microservice create/update **strips** user-supplied **`serviceAccount`** volume mappings instead of rejecting them — allows GET → PATCH round-trips; system still injects the canonical binding. - OIDC middleware **skips Bearer validation** on public catalog routes (e.g. **`GET /api/v3/status`**, **`GET /api/v3/architectures/`**, OAuth BFF) so agent/controller tokens on health checks no longer spam JWKS warnings. diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 00ee338d..94b48ae6 100755 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -7532,6 +7532,9 @@ components: type: string expirationTime: type: number + caCert: + type: string + description: Base64-encoded PEM of the controller CA for Edgelet trust store; empty when a public CA is used AgentProvisioningRequest: type: object required: diff --git a/src/config/embedded-oidc.js b/src/config/embedded-oidc.js index dfaf73f5..132f0e25 100644 --- a/src/config/embedded-oidc.js +++ b/src/config/embedded-oidc.js @@ -265,10 +265,38 @@ function resetEmbeddedIssuerForTests () { providerInstance = null } +function getEmbeddedIssuerMetadata (issuerUrl) { + const base = issuerUrl.replace(/\/$/, '') + return { + issuer: base, + authorization_endpoint: `${base}/auth`, + token_endpoint: `${base}/token`, + jwks_uri: `${base}/jwks`, + userinfo_endpoint: `${base}/me`, + end_session_endpoint: `${base}/session/end`, + revocation_endpoint: `${base}/revoke`, + pushed_authorization_request_endpoint: `${base}/request`, + response_types_supported: ['code id_token', 'code', 'id_token', 'none'], + grant_types_supported: ['implicit', 'authorization_code'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_jwt', + 'client_secret_post', + 'private_key_jwt', + 'none' + ], + id_token_signing_alg_values_supported: ['RS256'], + subject_types_supported: ['public'], + scopes_supported: ['openid', 'profile', 'email', 'groups'] + } +} + module.exports = { initEmbeddedIssuer, getEmbeddedProvider, resetEmbeddedIssuerForTests, getOauthInteractionPath, - buildInteractionRedirectUrl + buildInteractionRedirectUrl, + getEmbeddedIssuerMetadata } diff --git a/src/config/oidc-fetch.js b/src/config/oidc-fetch.js new file mode 100644 index 00000000..9307398f --- /dev/null +++ b/src/config/oidc-fetch.js @@ -0,0 +1,37 @@ +const { Agent, fetch: undiciFetch } = require('undici') +const { getListenerTlsMaterial, getListenerTrustCaBuffers } = require('../utils/tls-config') + +let cachedFetch + +function resetEmbeddedOidcFetchForTests () { + cachedFetch = undefined +} + +function createEmbeddedOidcFetch () { + const material = getListenerTlsMaterial() + if (!material.enabled) { + return undefined + } + + const caBuffers = getListenerTrustCaBuffers(material) + if (!caBuffers?.length) { + return undefined + } + + if (!cachedFetch) { + const agent = new Agent({ + connect: { + ca: caBuffers, + rejectUnauthorized: true + } + }) + cachedFetch = (url, init) => undiciFetch(url, { ...init, dispatcher: agent }) + } + + return cachedFetch +} + +module.exports = { + createEmbeddedOidcFetch, + resetEmbeddedOidcFetchForTests +} diff --git a/src/config/oidc.js b/src/config/oidc.js index 361d228a..355b431e 100644 --- a/src/config/oidc.js +++ b/src/config/oidc.js @@ -282,15 +282,33 @@ async function ensureEmbeddedOauthClient () { const { issuerUrl } = getOidcSettings() const db = require('../data/models') const { resolveConfidentialClientSecret } = require('./embedded-oidc-client-secret') + const { getEmbeddedIssuerMetadata } = require('./embedded-oidc') + const { createEmbeddedOidcFetch } = require('./oidc-fetch') const { clientId, clientSecret } = await resolveConfidentialClientSecret(db) - embeddedOauthClientPromise = oidcClient.discovery( - new URL(issuerUrl), - clientId, - clientSecret, - undefined, - getDiscoveryOptions() - ).catch((error) => { + embeddedOauthClientPromise = (async () => { + const serverMetadata = getEmbeddedIssuerMetadata(issuerUrl) + const configuration = new oidcClient.Configuration( + serverMetadata, + clientId, + clientSecret, + undefined + ) + + const discoveryOptions = getDiscoveryOptions() + if (discoveryOptions?.execute) { + for (const extension of discoveryOptions.execute) { + extension(configuration) + } + } + + const customFetch = createEmbeddedOidcFetch() + if (customFetch) { + configuration[oidcClient.customFetch] = customFetch + } + + return configuration + })().catch((error) => { embeddedOauthClientPromise = null throw error }) @@ -313,6 +331,8 @@ function resetDiscoveryForTests () { issuerString = null configuredClientId = null oidcInstance = null + const { resetEmbeddedOidcFetchForTests } = require('./oidc-fetch') + resetEmbeddedOidcFetchForTests() const { resetAuthSessionStoreForTests } = require('./auth-session-store') resetAuthSessionStoreForTests() } diff --git a/src/server.js b/src/server.js index 49051d03..05486200 100755 --- a/src/server.js +++ b/src/server.js @@ -258,18 +258,10 @@ initialize().then(() => { const consoleURL = getConsoleUrl() const consolePath = resolveConsolePath() - // File-based TLS configuration - const tlsKey = process.env.TLS_PATH_KEY || config.get('server.tls.path.key') - const tlsCert = process.env.TLS_PATH_CERT || config.get('server.tls.path.cert') - const intermedKey = process.env.TLS_PATH_INTERMEDIATE_CERT || config.get('server.tls.path.intermediateCert') - - // Base64 TLS configuration - const tlsKeyBase64 = process.env.TLS_BASE64_KEY || config.get('server.tls.base64.key') - const tlsCertBase64 = process.env.TLS_BASE64_CERT || config.get('server.tls.base64.cert') - const intermedKeyBase64 = process.env.TLS_BASE64_INTERMEDIATE_CERT || config.get('server.tls.base64.intermediateCert') - - const hasFileBasedTLS = !devMode && tlsKey && tlsCert - const hasBase64TLS = !devMode && tlsKeyBase64 && tlsCertBase64 + const { getListenerTlsMaterial } = require('./utils/tls-config') + const tlsMaterial = getListenerTlsMaterial() + const hasFileBasedTLS = tlsMaterial.enabled && !tlsMaterial.isBase64 + const hasBase64TLS = tlsMaterial.enabled && tlsMaterial.isBase64 consoleApp.use(express.static(consolePath, { index: 'index.html' })) consoleApp.get('*', (req, res, next) => { @@ -375,9 +367,9 @@ initialize().then(() => { startHttpsServer( { api: app, console: consoleApp }, { api: apiPort, console: consolePort }, - tlsKey, - tlsCert, - intermedKey, + tlsMaterial.key, + tlsMaterial.cert, + tlsMaterial.intermediateCert, jobs, false ) @@ -385,9 +377,9 @@ initialize().then(() => { startHttpsServer( { api: app, console: consoleApp }, { api: apiPort, console: consolePort }, - tlsKeyBase64, - tlsCertBase64, - intermedKeyBase64, + tlsMaterial.key, + tlsMaterial.cert, + tlsMaterial.intermediateCert, jobs, true ) diff --git a/src/services/agent-service.js b/src/services/agent-service.js index d4695387..432b27bf 100644 --- a/src/services/agent-service.js +++ b/src/services/agent-service.js @@ -1,9 +1,8 @@ const config = require('../config') -const fs = require('fs') // const Sequelize = require('sequelize') const moment = require('moment') // const Op = Sequelize.Op -const logger = require('../logger') +// const logger = require('../logger') const TransactionDecorator = require('../decorators/transaction-decorator') const FogProvisionKeyManager = require('../data/managers/iofog-provision-key-manager') @@ -611,51 +610,17 @@ async function _checkMicroservicesFogType (fog, archId, transaction) { const getControllerCA = async function (fog, transaction) { const devMode = config.getBoolean('server.devMode', false) - const sslCert = process.env.SSL_CERT || config.get('server.ssl.path.cert') - const intermedKey = process.env.INTERMEDIATE_CERT || config.get('server.ssl.path.intermediateCert') - const sslCertBase64 = config.get('server.ssl.base64.cert') - const intermedKeyBase64 = config.get('server.ssl.base64.intermediateCert') - const hasFileBasedSSL = !devMode && sslCert - const hasBase64SSL = !devMode && sslCertBase64 - if (devMode) { throw new Errors.ValidationError('Controller is in development mode') } - if (hasFileBasedSSL) { - try { - if (intermedKey) { - // Check if intermediate certificate file exists before trying to read it - if (fs.existsSync(intermedKey)) { - const certData = fs.readFileSync(intermedKey, 'utf8') - return Buffer.from(certData).toString('base64') - } else { - // Intermediate certificate file doesn't exist, don't provide any CA cert - // Let the system's default trust store handle validation - logger.info(`Intermediate certificate file not found at path: ${intermedKey}, not providing CA certificate`) - return '' - } - } else { - // No intermediate certificate path provided, don't provide any CA cert - // Let the system's default trust store handle validation - return '' - } - } catch (error) { - throw new Errors.ValidationError('Failed to read SSL certificate file') - } - } - - if (hasBase64SSL) { - if (intermedKeyBase64) { - return intermedKeyBase64 - } else { - // No intermediate certificate base64 provided, don't provide any CA cert - // Let the system's default trust store handle validation - return '' - } + const { getListenerTlsMaterial, getListenerTrustCaBase64 } = require('../utils/tls-config') + const material = getListenerTlsMaterial() + if (!material.enabled) { + throw new Errors.ValidationError('No valid SSL certificate configuration found') } - throw new Errors.ValidationError('No valid SSL certificate configuration found') + return getListenerTrustCaBase64(material) } // New endpoint: Get active log sessions for agent diff --git a/src/services/iofog-service.js b/src/services/iofog-service.js index c0b99c98..3630f1d7 100644 --- a/src/services/iofog-service.js +++ b/src/services/iofog-service.js @@ -1,5 +1,4 @@ const config = require('../config') -const fs = require('fs') const TransactionDecorator = require('../decorators/transaction-decorator') const AppHelper = require('../helpers/app-helper') const FogManager = require('../data/managers/iofog-manager') @@ -1094,49 +1093,9 @@ async function generateProvisioningKeyEndPoint (fogData, isCLI, transaction) { } const provisioningKeyData = await refreshProvisionKeyForFog(fogData.uuid, transaction) + const { getListenerTrustCaBase64 } = require('../utils/tls-config') + const caCert = getListenerTrustCaBase64() - const devMode = config.getBoolean('server.devMode', false) - const sslCert = process.env.SSL_CERT || config.get('server.ssl.path.cert') - const intermedKey = process.env.INTERMEDIATE_CERT || config.get('server.ssl.path.intermediateCert') - const sslCertBase64 = config.get('server.ssl.base64.cert') - const intermedKeyBase64 = config.get('server.ssl.base64.intermediateCert') - const hasFileBasedSSL = !devMode && sslCert - const hasBase64SSL = !devMode && sslCertBase64 - let caCert = '' - - if (!devMode) { - if (hasFileBasedSSL) { - try { - if (intermedKey) { - // Check if intermediate certificate file exists before trying to read it - if (fs.existsSync(intermedKey)) { - const certData = fs.readFileSync(intermedKey) - caCert = Buffer.from(certData).toString('base64') - } else { - // Intermediate certificate file doesn't exist, don't provide any CA cert - // Let the system's default trust store handle validation - logger.info(`Intermediate certificate file not found at path: ${intermedKey}, not providing CA certificate`) - caCert = '' - } - } else { - // No intermediate certificate path provided, don't provide any CA cert - // Let the system's default trust store handle validation - caCert = '' - } - } catch (error) { - throw new Errors.ValidationError('Failed to read SSL certificate file') - } - } - if (hasBase64SSL) { - if (intermedKeyBase64) { - caCert = intermedKeyBase64 - } else { - // No intermediate certificate base64 provided, don't provide any CA cert - // Let the system's default trust store handle validation - caCert = '' - } - } - } return { key: provisioningKeyData.provisionKey, expirationTime: provisioningKeyData.expirationTime, diff --git a/src/utils/tls-config.js b/src/utils/tls-config.js new file mode 100644 index 00000000..5e3cd5f8 --- /dev/null +++ b/src/utils/tls-config.js @@ -0,0 +1,104 @@ +const { X509Certificate } = require('crypto') +const config = require('../config') +const { loadCertificate } = require('./ssl-utils') + +function trimEnv (value) { + if (typeof value !== 'string') { + return value + } + return value.trim() +} + +function getListenerTlsMaterial () { + const devMode = config.getBoolean('server.devMode', true) + if (devMode) { + return { enabled: false } + } + + const tlsKey = trimEnv(process.env.TLS_PATH_KEY || config.get('server.tls.path.key')) + const tlsCert = trimEnv(process.env.TLS_PATH_CERT || config.get('server.tls.path.cert')) + const intermediateCert = trimEnv( + process.env.TLS_PATH_INTERMEDIATE_CERT || config.get('server.tls.path.intermediateCert') + ) + + if (tlsKey && tlsCert) { + return { + enabled: true, + key: tlsKey, + cert: tlsCert, + intermediateCert, + isBase64: false + } + } + + const tlsKeyBase64 = process.env.TLS_BASE64_KEY || config.get('server.tls.base64.key') + const tlsCertBase64 = process.env.TLS_BASE64_CERT || config.get('server.tls.base64.cert') + const intermediateCertBase64 = process.env.TLS_BASE64_INTERMEDIATE_CERT || + config.get('server.tls.base64.intermediateCert') + + if (tlsKeyBase64 && tlsCertBase64) { + return { + enabled: true, + key: tlsKeyBase64, + cert: tlsCertBase64, + intermediateCert: intermediateCertBase64, + isBase64: true + } + } + + return { enabled: false } +} + +function isSelfSignedCertBuffer (certBuffer) { + try { + const x509 = new X509Certificate(certBuffer) + return x509.issuer === x509.subject + } catch { + return false + } +} + +function getListenerTrustCaBuffers (material = getListenerTlsMaterial()) { + if (!material?.enabled) { + return undefined + } + + const buffers = [] + + if (material.intermediateCert) { + try { + buffers.push(loadCertificate(material.intermediateCert, material.isBase64)) + } catch { + // Intermediate cert is optional for listener startup; omit from trust store if unreadable. + } + } + + if (buffers.length === 0) { + try { + const leaf = loadCertificate(material.cert, material.isBase64) + if (isSelfSignedCertBuffer(leaf)) { + buffers.push(leaf) + } + } catch { + // Fall through to system CAs when leaf cert cannot be loaded. + } + } + + return buffers.length ? buffers : undefined +} + +function getListenerTrustCaBase64 (material = getListenerTlsMaterial()) { + const buffers = getListenerTrustCaBuffers(material) + if (!buffers?.length) { + return '' + } + + return buffers[0].toString('base64') +} + +module.exports = { + getListenerTlsMaterial, + getListenerTrustCaBuffers, + getListenerTrustCaBase64, + isSelfSignedCertBuffer +} diff --git a/test/src/config/oidc.test.js b/test/src/config/oidc.test.js index bad73a21..36ccb2de 100644 --- a/test/src/config/oidc.test.js +++ b/test/src/config/oidc.test.js @@ -257,7 +257,7 @@ describe('OIDC config', () => { await $harness }) - it('discovers the embedded issuer over HTTP when auth.insecureAllowHttp is true', async () => { + it('builds embedded oauth client from local metadata without network discovery', async () => { await withEmbeddedIssuerServer(async (issuerBase) => { const originalGet = config.get.bind(config) $sandbox.stub(config, 'get').callsFake((key, defaultValue) => { @@ -272,21 +272,88 @@ describe('OIDC config', () => { const clientConfig = await oidc.getOauthClientConfiguration() expect(clientConfig.serverMetadata().issuer).to.equal(`${issuerBase}/oidc`) + expect(clientConfig.serverMetadata().token_endpoint).to.equal(`${issuerBase}/oidc/token`) }) }) - it('rejects HTTP issuer discovery when auth.insecureAllowHttp is false', async () => { + it('builds embedded oauth client from local metadata when auth.insecureAllowHttp is false', async () => { await withEmbeddedIssuerServer(async (issuerBase) => { const oidc = reloadOidcModule() oidc.initOidc() - try { - await oidc.getOauthClientConfiguration() - expect.fail('expected HTTP discovery to be rejected') - } catch (error) { - expect(error.message).to.equal('only requests to HTTPS are allowed') + const clientConfig = await oidc.getOauthClientConfiguration() + expect(clientConfig.serverMetadata().issuer).to.equal(`${issuerBase}/oidc`) + }) + }) + + it('attaches listener TLS trust for embedded HTTPS without NODE_EXTRA_CA_CERTS', async () => { + const path = require('path') + const https = require('https') + const oidcClient = require('openid-client') + const { createSSLOptions } = require('../../../src/utils/ssl-utils') + const { resetEmbeddedOidcFetchForTests } = require('../../../src/config/oidc-fetch') + + const certDir = path.join(__dirname, '../../tls-cert') + const sslOptions = createSSLOptions({ + key: path.join(certDir, 'tls.key'), + cert: path.join(certDir, 'tls.crt'), + intermedKey: path.join(certDir, 'ca.crt'), + isBase64: false + }) + + applyEmbeddedEnv({ + CONTROLLER_PUBLIC_URL: 'https://localhost:0', + OIDC_CLIENT_SECRET: 'embedded-oauth-test-secret' + }) + + const originalGetBoolean = config.getBoolean.bind(config) + $sandbox.stub(config, 'getBoolean').callsFake((key, defaultValue = false) => { + if (key === 'server.devMode') { + return false } + return originalGetBoolean(key, defaultValue) + }) + + process.env.TLS_PATH_KEY = path.join(certDir, 'tls.key') + process.env.TLS_PATH_CERT = path.join(certDir, 'tls.crt') + process.env.TLS_PATH_INTERMEDIATE_CERT = path.join(certDir, 'ca.crt') + + const server = await new Promise((resolve, reject) => { + const listener = https.createServer(sslOptions, (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ issuer: 'https://localhost:0/oidc' })) + }) + listener.listen(0, 'localhost', () => resolve(listener)) + listener.on('error', reject) }) + + const { port } = server.address() + process.env.CONTROLLER_PUBLIC_URL = `https://localhost:${port}` + + try { + resetEmbeddedOidcFetchForTests() + const oidc = reloadOidcModule() + oidc.initOidc() + + const clientConfig = await oidc.getOauthClientConfiguration() + expect(clientConfig.serverMetadata().issuer).to.equal(`https://localhost:${port}/oidc`) + + const customFetch = clientConfig[oidcClient.customFetch] + expect(customFetch).to.be.a('function') + + const response = await customFetch( + `https://localhost:${port}/oidc/.well-known/openid-configuration` + ) + expect(response.status).to.equal(200) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + delete process.env.TLS_PATH_KEY + delete process.env.TLS_PATH_CERT + delete process.env.TLS_PATH_INTERMEDIATE_CERT + resetEmbeddedOidcFetchForTests() + } }) }) }) diff --git a/test/src/services/agent-service.test.js b/test/src/services/agent-service.test.js index 36c1dd75..b20223b5 100644 --- a/test/src/services/agent-service.test.js +++ b/test/src/services/agent-service.test.js @@ -25,6 +25,8 @@ const path = require('path') const { microserviceState } = require('../../../src/enums/microservice-state') const FogStates = require('../../../src/enums/fog-state') const constants = require('../../../src/helpers/constants') +const config = require('../../../src/config') +const Errors = require('../../../src/helpers/errors') global.appRoot = path.resolve(__dirname) @@ -1686,4 +1688,56 @@ describe('Agent Service', () => { }) }) + describe('.getControllerCA()', () => { + const fs = require('fs') + const transaction = {} + const fog = { uuid: 'test-fog-uuid' } + + def('subject', () => $subject.getControllerCA(fog, transaction)) + + context('when listener TLS intermediate cert is configured via TLS_PATH_*', () => { + const certDir = path.join(__dirname, '../../tls-cert') + + beforeEach(() => { + const originalGetBoolean = config.getBoolean.bind(config) + $sandbox.stub(config, 'getBoolean').callsFake((key, defaultValue = false) => { + if (key === 'server.devMode') { + return false + } + return originalGetBoolean(key, defaultValue) + }) + + process.env.TLS_PATH_KEY = path.join(certDir, 'tls.key') + process.env.TLS_PATH_CERT = path.join(certDir, 'tls.crt') + process.env.TLS_PATH_INTERMEDIATE_CERT = path.join(certDir, 'ca.crt') + }) + + afterEach(() => { + delete process.env.TLS_PATH_KEY + delete process.env.TLS_PATH_CERT + delete process.env.TLS_PATH_INTERMEDIATE_CERT + }) + + it('returns base64-encoded controller CA', async () => { + const expectedCaCert = fs.readFileSync(path.join(certDir, 'ca.crt')).toString('base64') + await expect($subject).to.eventually.equal(expectedCaCert) + }) + }) + + context('when controller is in dev mode', () => { + beforeEach(() => { + $sandbox.stub(config, 'getBoolean').callsFake((key, defaultValue = false) => { + if (key === 'server.devMode') { + return true + } + return defaultValue + }) + }) + + it('rejects with ValidationError', () => { + return expect($subject).to.be.rejectedWith(Errors.ValidationError) + }) + }) + }) + }) diff --git a/test/src/services/iofog-service.test.js b/test/src/services/iofog-service.test.js index e60b8768..325a2352 100644 --- a/test/src/services/iofog-service.test.js +++ b/test/src/services/iofog-service.test.js @@ -24,6 +24,7 @@ const ioFogVersionCommandManager = require('../../../src/data/managers/iofog-ver const HWInfoManager = require('../../../src/data/managers/hw-info-manager') const USBInfoManager = require('../../../src/data/managers/usb-info-manager') const Errors = require('../../../src/helpers/errors') +const config = require('../../../src/config') const isCLI = false const transaction = {} @@ -425,6 +426,71 @@ describe('ioFog Service', () => { expect(ioFogProvisionKeyManager.updateOrCreate).to.have.been.calledOnce }) + context('when listener TLS intermediate cert is configured via TLS_PATH_*', () => { + const fs = require('fs') + const path = require('path') + const certDir = path.join(__dirname, '../../tls-cert') + + beforeEach(() => { + const originalGetBoolean = config.getBoolean.bind(config) + $sandbox.stub(config, 'getBoolean').callsFake((key, defaultValue = false) => { + if (key === 'server.devMode') { + return false + } + return originalGetBoolean(key, defaultValue) + }) + + process.env.TLS_PATH_KEY = path.join(certDir, 'tls.key') + process.env.TLS_PATH_CERT = path.join(certDir, 'tls.crt') + process.env.TLS_PATH_INTERMEDIATE_CERT = path.join(certDir, 'ca.crt') + }) + + afterEach(() => { + delete process.env.TLS_PATH_KEY + delete process.env.TLS_PATH_CERT + delete process.env.TLS_PATH_INTERMEDIATE_CERT + }) + + it('returns base64-encoded caCert for Edgelet trust store', async () => { + const expectedCaCert = fs.readFileSync(path.join(certDir, 'ca.crt')).toString('base64') + const result = await $subject + expect(result.caCert).to.equal(expectedCaCert) + }) + }) + + context('when listener TLS intermediate cert is configured via TLS_BASE64_*', () => { + const fs = require('fs') + const path = require('path') + const certDir = path.join(__dirname, '../../tls-cert') + + beforeEach(() => { + const originalGetBoolean = config.getBoolean.bind(config) + $sandbox.stub(config, 'getBoolean').callsFake((key, defaultValue = false) => { + if (key === 'server.devMode') { + return false + } + return originalGetBoolean(key, defaultValue) + }) + + const toBase64 = (fileName) => fs.readFileSync(path.join(certDir, fileName)).toString('base64') + process.env.TLS_BASE64_KEY = toBase64('tls.key') + process.env.TLS_BASE64_CERT = toBase64('tls.crt') + process.env.TLS_BASE64_INTERMEDIATE_CERT = toBase64('ca.crt') + }) + + afterEach(() => { + delete process.env.TLS_BASE64_KEY + delete process.env.TLS_BASE64_CERT + delete process.env.TLS_BASE64_INTERMEDIATE_CERT + }) + + it('returns base64-encoded caCert regardless of TLS config encoding', async () => { + const expectedCaCert = fs.readFileSync(path.join(certDir, 'ca.crt')).toString('base64') + const result = await $subject + expect(result.caCert).to.equal(expectedCaCert) + }) + }) + context('when fog is missing', () => { beforeEach(() => { ioFogManager.findOne.resolves(null) diff --git a/test/src/utils/tls-config.test.js b/test/src/utils/tls-config.test.js new file mode 100644 index 00000000..8cba23ae --- /dev/null +++ b/test/src/utils/tls-config.test.js @@ -0,0 +1,128 @@ +const { expect } = require('chai') +const fs = require('fs') +const path = require('path') +const sinon = require('sinon') + +const config = require('../../../src/config') +const { + getListenerTlsMaterial, + getListenerTrustCaBuffers, + getListenerTrustCaBase64, + isSelfSignedCertBuffer +} = require('../../../src/utils/tls-config') + +describe('tls-config', () => { + def('sandbox', () => sinon.createSandbox()) + def('certDir', () => path.join(__dirname, '../../tls-cert')) + + afterEach(() => { + $sandbox.restore() + delete process.env.TLS_PATH_KEY + delete process.env.TLS_PATH_CERT + delete process.env.TLS_PATH_INTERMEDIATE_CERT + delete process.env.TLS_BASE64_KEY + delete process.env.TLS_BASE64_CERT + delete process.env.TLS_BASE64_INTERMEDIATE_CERT + }) + + it('returns disabled material in dev mode', () => { + $sandbox.stub(config, 'getBoolean').callsFake((key, defaultValue = false) => { + if (key === 'server.devMode') { + return true + } + return defaultValue + }) + + expect(getListenerTlsMaterial()).to.deep.equal({ enabled: false }) + }) + + it('loads file-based listener TLS material in production mode', () => { + $sandbox.stub(config, 'getBoolean').callsFake((key, defaultValue = false) => { + if (key === 'server.devMode') { + return false + } + return defaultValue + }) + + process.env.TLS_PATH_KEY = path.join($certDir, 'tls.key') + process.env.TLS_PATH_CERT = path.join($certDir, 'tls.crt') + process.env.TLS_PATH_INTERMEDIATE_CERT = path.join($certDir, 'ca.crt') + + expect(getListenerTlsMaterial()).to.include({ + enabled: true, + isBase64: false + }) + }) + + it('loads base64 listener TLS material in production mode', () => { + $sandbox.stub(config, 'getBoolean').callsFake((key, defaultValue = false) => { + if (key === 'server.devMode') { + return false + } + return defaultValue + }) + + const toBase64 = (fileName) => fs.readFileSync(path.join($certDir, fileName)).toString('base64') + process.env.TLS_BASE64_KEY = toBase64('tls.key') + process.env.TLS_BASE64_CERT = toBase64('tls.crt') + process.env.TLS_BASE64_INTERMEDIATE_CERT = toBase64('ca.crt') + + expect(getListenerTlsMaterial()).to.include({ + enabled: true, + isBase64: true + }) + }) + + it('derives trust CA buffers from intermediate cert path', () => { + const material = { + enabled: true, + cert: path.join($certDir, 'tls.crt'), + intermediateCert: path.join($certDir, 'ca.crt'), + isBase64: false + } + + const caBuffers = getListenerTrustCaBuffers(material) + expect(caBuffers).to.have.length(1) + expect(isSelfSignedCertBuffer(caBuffers[0])).to.equal(true) + }) + + it('falls back to self-signed leaf cert when intermediate is absent', () => { + const material = { + enabled: true, + cert: path.join($certDir, 'tls.crt'), + intermediateCert: undefined, + isBase64: false + } + + const caBuffers = getListenerTrustCaBuffers(material) + expect(caBuffers).to.have.length(1) + }) + + it('returns base64-encoded CA from file-based intermediate cert', () => { + const expected = fs.readFileSync(path.join($certDir, 'ca.crt')).toString('base64') + const material = { + enabled: true, + cert: path.join($certDir, 'tls.crt'), + intermediateCert: path.join($certDir, 'ca.crt'), + isBase64: false + } + + expect(getListenerTrustCaBase64(material)).to.equal(expected) + }) + + it('returns base64-encoded CA from base64 intermediate cert config', () => { + const expected = fs.readFileSync(path.join($certDir, 'ca.crt')).toString('base64') + const material = { + enabled: true, + cert: fs.readFileSync(path.join($certDir, 'tls.crt')).toString('base64'), + intermediateCert: expected, + isBase64: true + } + + expect(getListenerTrustCaBase64(material)).to.equal(expected) + }) + + it('returns empty string when no trust CA can be derived', () => { + expect(getListenerTrustCaBase64({ enabled: false })).to.equal('') + }) +}) diff --git a/test/tls-cert/ca.crt b/test/tls-cert/ca.crt new file mode 100644 index 00000000..39da0fc3 --- /dev/null +++ b/test/tls-cert/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIWCAByg3UJMWkAI3gzAhd5VJlhlmYONzANBgkqhkiG9w0B +AQsFADAaMRgwFgYDVQQDEw90ZXN0LWNvbnRyb2xsZXIwHhcNMjYwNjIzMTkzMjU4 +WhcNMjkwNjIzMTU0MjM0WjAaMRgwFgYDVQQDEw90ZXN0LWNvbnRyb2xsZXIwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCOS70qVb/WC9EPfCBS5KaV7O9z +/kAgxtdEBMchkK3q0rFIvs8zlei6iuGJbJCroDemC+qGU9DoKNNN5bc6EQ0uiaDk +847azR/uNnDHoWOw5SUcJ4ia0rNH4KSMTMPY6hzkiIAgmOlUsLuklbacp7FkT9a0 +tpu1QPtM41LP59omDka4hvLFQQUNZ6GqGd5GxtBJYaf+Hb7zk88gVm7tm8Cfy3Vq +VJ8+25DnxTQ8PLZdx24ixMYxRTsCnMb9HsT9WfMfc2cS64w8t/KSi5RD92anGqgZ +EtLoQLkBjXz7V0/dL/IMjCOZTQypAq331DWpiRtFbgStRTNBwY0Q15Qp5oUFAgMB +AAGjdDByMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0O +BBYEFJDdki3PxlVJ/8x2qA2F0F4x593AMA0GCSqGSIb3DQEBCwUAA4IBAQAivosP +h23eQIvO7eiVxZ43YGVvctZBooEys66b5t6h53iFoQOvsR8DfDy4GdZVhkZt2bA6 +vlf4aL7SamTu/UE1CtzvalF7F3snPziDm9bCJybm6HNQB0ktiPPR4NgQyoWz6KPH +Lg5jm95aDevJKhvaUKzWJjn8v8iTfoWoCrDwTf04QF4T5ml9+blStvl3Xr+bH5V0 +XE3949Fg/ysj3gGmqHpEm/F7wi2C7O12uJiu/UbTGm+yVQnl7MwJqkL3QYsWKq8M +H9V5viahLp5gsIB4ijST3PsbWapIysGZ1C5nIpE7xMZD8t6vdtZ9dbv1FqtnU4kZ +9iQdkpo1PcoIDAvY +-----END CERTIFICATE----- diff --git a/test/tls-cert/tls.crt b/test/tls-cert/tls.crt new file mode 100644 index 00000000..39da0fc3 --- /dev/null +++ b/test/tls-cert/tls.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIWCAByg3UJMWkAI3gzAhd5VJlhlmYONzANBgkqhkiG9w0B +AQsFADAaMRgwFgYDVQQDEw90ZXN0LWNvbnRyb2xsZXIwHhcNMjYwNjIzMTkzMjU4 +WhcNMjkwNjIzMTU0MjM0WjAaMRgwFgYDVQQDEw90ZXN0LWNvbnRyb2xsZXIwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCOS70qVb/WC9EPfCBS5KaV7O9z +/kAgxtdEBMchkK3q0rFIvs8zlei6iuGJbJCroDemC+qGU9DoKNNN5bc6EQ0uiaDk +847azR/uNnDHoWOw5SUcJ4ia0rNH4KSMTMPY6hzkiIAgmOlUsLuklbacp7FkT9a0 +tpu1QPtM41LP59omDka4hvLFQQUNZ6GqGd5GxtBJYaf+Hb7zk88gVm7tm8Cfy3Vq +VJ8+25DnxTQ8PLZdx24ixMYxRTsCnMb9HsT9WfMfc2cS64w8t/KSi5RD92anGqgZ +EtLoQLkBjXz7V0/dL/IMjCOZTQypAq331DWpiRtFbgStRTNBwY0Q15Qp5oUFAgMB +AAGjdDByMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0O +BBYEFJDdki3PxlVJ/8x2qA2F0F4x593AMA0GCSqGSIb3DQEBCwUAA4IBAQAivosP +h23eQIvO7eiVxZ43YGVvctZBooEys66b5t6h53iFoQOvsR8DfDy4GdZVhkZt2bA6 +vlf4aL7SamTu/UE1CtzvalF7F3snPziDm9bCJybm6HNQB0ktiPPR4NgQyoWz6KPH +Lg5jm95aDevJKhvaUKzWJjn8v8iTfoWoCrDwTf04QF4T5ml9+blStvl3Xr+bH5V0 +XE3949Fg/ysj3gGmqHpEm/F7wi2C7O12uJiu/UbTGm+yVQnl7MwJqkL3QYsWKq8M +H9V5viahLp5gsIB4ijST3PsbWapIysGZ1C5nIpE7xMZD8t6vdtZ9dbv1FqtnU4kZ +9iQdkpo1PcoIDAvY +-----END CERTIFICATE----- diff --git a/test/tls-cert/tls.key b/test/tls-cert/tls.key new file mode 100644 index 00000000..fcb4d6a7 --- /dev/null +++ b/test/tls-cert/tls.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAjku9KlW/1gvRD3wgUuSmlezvc/5AIMbXRATHIZCt6tKxSL7P +M5XouorhiWyQq6A3pgvqhlPQ6CjTTeW3OhENLomg5POO2s0f7jZwx6FjsOUlHCeI +mtKzR+CkjEzD2Ooc5IiAIJjpVLC7pJW2nKexZE/WtLabtUD7TONSz+faJg5GuIby +xUEFDWehqhneRsbQSWGn/h2+85PPIFZu7ZvAn8t1alSfPtuQ58U0PDy2XcduIsTG +MUU7ApzG/R7E/VnzH3NnEuuMPLfykouUQ/dmpxqoGRLS6EC5AY18+1dP3S/yDIwj +mU0MqQKt99Q1qYkbRW4ErUUzQcGNENeUKeaFBQIDAQABAoIBADF4I0uUhBzl/shj +XtlypHd658Rrn9/FQRFkl9YTdmMs3HVt4JsOgtpXbhFYrPL9wFr5yY7plLchelSa +iBin1O1Y65atFfz7Ux59zreYZBAbmcsv803f8nQKpSohhfTjbygETIcIohXPW2zc +n5/WrNUy7HHnCjr5XGReo2ukLzMLPsdzewQq714zOcXgE5oDDn20Q+H0p9buCbT3 +/VitGNxjdSbqi3Ei/8cw7TN9nF4J6jEAHbpry6cSNl8/4CxJ7GrPSksvOo0kUiuS +7YPJkBHVtdnHuXcIxxAwZfLC+Qy96hsWiN9Q32hAAMuPPIRPVIxhoa3R/2xoacdq +iVsIzAECgYEAxHnI5qwPk8JGASnVuABfnIcLL2pj2xhKrLnmEx8/uINzb1FCkE5D +9k7V63NR/k1Ses27rI1rV7Msh7Lsl8C4EQHIhIwgsH6yB7p9Ufkhf8gTrpTpTFpW +y68YV+FSJ1SJULsXxPImWOtGY7hgZUfnGSQj42meTTENVjWbMChlGoMCgYEAuWfi +sZXj63Aiy7mA/KIxiJrBmWfx3rFU/g1AVSWc6c1IucG0Amdw8SJ0cs14NCh55EnG +Duo3TYZ5Q8SNglBLUEPT/v48aXIrrYqx7p8SFU4QPJQCelf0vCSH887GGrG9voU4 +/9h62e5IDbakn0NMJ+5X/pLkO6f8mB3iUIcN69cCgYBVRG01/tI71AJBmwBPfIoC +CnGGQGvZt+8giwUYo2UqFJJSazeyHOVNzeT06/VXogL7nLGoLy+4vd/DfJlFOrQn +XVjCfXXqYvGsfPjKTI6HQDOafrHJGyOz/edYrEbVHtEBIlEsfdK6oFDKEkhzbAMV +XCPfHAVBhto84cLZ4Y4PNQKBgQCB994E6PL11wy9TROrhTM99GYkWKZHmn3e/YEM +byp5BuulM7ExQjv8/U8uLvTFc15G9qQ9TlhoIw0cwUsFf63b3UBd7vCAZoVRyPkE +MkQl5SwimwrFoqSvwtf+xANBMTm2tYMIbkNoQ84EtvTdo/pdv4m1rlkJrK+4HrLI +CnRqzwKBgGHSFEA1huEAnVxqx5jUQovrSd+UX+9vsShfeoEMiFsngqAOQAwg4wp4 +lDRlWCzD7Msa4jvNeUGVymaQJV2GeZSaLEVDSUrypELmhpn0pLMpiDE4AKXQK4Vq +LcOUtRff4YbUgET8yX7DpvN2dgzDdZ191J/kMSDv/3v5lYKvgOHE +-----END RSA PRIVATE KEY-----