diff --git a/packages/backend/src/misc/gen-x509-cert-from-jwk.ts b/packages/backend/src/misc/gen-x509-cert-from-jwk.ts new file mode 100644 index 0000000000..1050716a49 --- /dev/null +++ b/packages/backend/src/misc/gen-x509-cert-from-jwk.ts @@ -0,0 +1,33 @@ +import forge from 'node-forge'; +import * as jose from 'jose'; + +export async function genX509CertFromJWK( + hostname: string, + notBefore: Date, + notAfter: Date, + publicKey: string, + privateKey: string, +): Promise { + const cert = forge.pki.createCertificate(); + cert.serialNumber = '01'; + cert.validity.notBefore = notBefore; + cert.validity.notAfter = notAfter; + + const attrs = [{ name: 'commonName', value: hostname }]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + cert.publicKey = await jose + .importJWK(JSON.parse(publicKey)) + .then((k) => jose.exportSPKI(k as jose.KeyLike)) + .then((k) => forge.pki.publicKeyFromPem(k)); + + cert.sign( + await jose + .importJWK(JSON.parse(privateKey)) + .then((k) => jose.exportPKCS8(k as jose.KeyLike)) + .then((k) => forge.pki.privateKeyFromPem(k)), + forge.md.sha256.create(), + ); + + return forge.pki.certificateToPem(cert); +} diff --git a/packages/backend/src/server/api/endpoints/admin/sso/create.ts b/packages/backend/src/server/api/endpoints/admin/sso/create.ts index 6e7847db1d..8d58880f5b 100644 --- a/packages/backend/src/server/api/endpoints/admin/sso/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/sso/create.ts @@ -1,10 +1,12 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import * as jose from 'jose'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; import type { SingleSignOnServiceProviderRepository } from '@/models/_.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { genX509CertFromJWK } from '@/misc/gen-x509-cert-from-jwk.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -108,6 +110,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.config) + private config: Config, @Inject(DI.singleSignOnServiceProviderRepository) private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository, @@ -125,16 +129,28 @@ export default class extends Endpoint { // eslint- })) : { publicKey: ps.secret ?? randomUUID(), privateKey: null }; + const now = new Date(); + const tenYearsLaterTime = new Date(now.getTime()); + tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10); + + const x509Cert = ps.type === 'saml' && ps.useCertificate ? await genX509CertFromJWK( + this.config.hostname, + now, + tenYearsLaterTime, + publicKey, + privateKey ?? '', + ) : undefined; + const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.insert({ id: randomUUID(), - createdAt: new Date(), + createdAt: now, name: ps.name ? ps.name : null, type: ps.type, issuer: ps.issuer, audience: ps.audience?.filter(i => i.length > 0) ?? [], binding: ps.binding, acsUrl: ps.acsUrl, - publicKey: publicKey, + publicKey: ps.type === 'saml' && ps.useCertificate ? x509Cert : publicKey, privateKey: privateKey, signatureAlgorithm: ps.signatureAlgorithm, cipherAlgorithm: ps.cipherAlgorithm ? ps.cipherAlgorithm : null, diff --git a/packages/backend/src/server/api/endpoints/admin/sso/update.ts b/packages/backend/src/server/api/endpoints/admin/sso/update.ts index d0d4d153b8..552415daf2 100644 --- a/packages/backend/src/server/api/endpoints/admin/sso/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/sso/update.ts @@ -1,9 +1,11 @@ -import * as jose from 'jose'; import { Inject, Injectable } from '@nestjs/common'; -import type { SingleSignOnServiceProviderRepository } from '@/models/_.js'; +import * as jose from 'jose'; import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { SingleSignOnServiceProviderRepository } from '@/models/_.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { genX509CertFromJWK } from '@/misc/gen-x509-cert-from-jwk.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -44,6 +46,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.config) + private config: Config, @Inject(DI.singleSignOnServiceProviderRepository) private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository, @@ -62,13 +66,26 @@ export default class extends Endpoint { // eslint- })) : { publicKey: ps.secret ?? undefined, privateKey: undefined }; + const now = new Date(); + const tenYearsLaterTime = new Date(now.getTime()); + tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10); + + const x509Cert = service.type === 'saml' && ps.regenerateCertificate ? await genX509CertFromJWK( + this.config.hostname, + now, + tenYearsLaterTime, + publicKey ?? '', + privateKey ?? '', + ) : undefined; + await this.singleSignOnServiceProviderRepository.update(service.id, { name: ps.name !== '' ? ps.name : null, + createdAt: service.type === 'saml' && ps.regenerateCertificate ? now : undefined, issuer: ps.issuer, audience: ps.audience?.filter(i => i.length > 0), binding: ps.binding, acsUrl: ps.acsUrl, - publicKey: publicKey, + publicKey: service.type === 'saml' && ps.regenerateCertificate ? x509Cert : publicKey, privateKey: privateKey, signatureAlgorithm: ps.signatureAlgorithm, cipherAlgorithm: ps.cipherAlgorithm !== '' ? ps.cipherAlgorithm : null, diff --git a/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts b/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts index 915f3bfd1a..b5f35ddf3d 100644 --- a/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts +++ b/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts @@ -1,6 +1,5 @@ import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; -import forge from 'node-forge'; import * as jose from 'jose'; import * as Redis from 'ioredis'; import * as saml from 'samlify'; @@ -56,28 +55,10 @@ export class SAMLIdentifyProviderService { public async createIdPMetadataXml( provider: MiSingleSignOnServiceProvider, ): Promise { - const nowTime = new Date(); - const tenYearsLaterTime = new Date(nowTime.getTime()); + const tenYearsLaterTime = new Date(provider.createdAt.getTime()); tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10); const tenYearsLater = tenYearsLaterTime.toISOString(); - const cert = forge.pki.createCertificate(); - cert.serialNumber = '01'; - cert.validity.notBefore = provider.createdAt; - cert.validity.notAfter = tenYearsLaterTime; - const attrs = [{ name: 'commonName', value: this.config.hostname }]; - cert.setSubject(attrs); - cert.setIssuer(attrs); - cert.publicKey = await jose.importJWK(JSON.parse(provider.publicKey)) - .then(k => jose.exportSPKI(k as jose.KeyLike)) - .then(k => forge.pki.publicKeyFromPem(k)); - cert.sign( - await jose.importJWK(JSON.parse(provider.privateKey ?? '{}')) - .then(k => jose.exportPKCS8(k as jose.KeyLike)) - .then(k => forge.pki.privateKeyFromPem(k)), - forge.md.sha256.create(), - ); - const nodes = { 'md:EntityDescriptor': { '@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata', @@ -92,7 +73,7 @@ export class SAMLIdentifyProviderService { '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', 'ds:X509Data': { 'ds:X509Certificate': { - '#text': forge.pki.certificateToPem(cert).replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''), + '#text': provider.publicKey.replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''), }, }, }, @@ -123,29 +104,10 @@ export class SAMLIdentifyProviderService { public async createSPMetadataXml( provider: MiSingleSignOnServiceProvider, ): Promise { - const nowTime = new Date(); - const tenYearsLaterTime = new Date(nowTime.getTime()); + const tenYearsLaterTime = new Date(provider.createdAt.getTime()); tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10); const tenYearsLater = tenYearsLaterTime.toISOString(); - const cert = forge.pki.createCertificate(); - cert.serialNumber = '01'; - cert.validity.notBefore = provider.createdAt; - cert.validity.notAfter = tenYearsLaterTime; - const attrs = [{ name: 'commonName', value: this.config.hostname }]; - cert.setSubject(attrs); - cert.setIssuer(attrs); - cert.publicKey = await jose.importJWK(JSON.parse(provider.publicKey)) - .then(k => jose.exportSPKI(k as jose.KeyLike)) - .then(k => forge.pki.publicKeyFromPem(k)); - cert.sign( - await jose.importJWK(JSON.parse(provider.privateKey ?? '{}')) - .then(k => jose.exportPKCS8(k as jose.KeyLike)) - .then(k => forge.pki.privateKeyFromPem(k)), - forge.md.sha256.create(), - ); - const x509 = forge.pki.certificateToPem(cert).replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''); - const keyDescriptor: unknown[] = [ { '@use': 'signing', @@ -153,7 +115,7 @@ export class SAMLIdentifyProviderService { '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', 'ds:X509Data': { 'ds:X509Certificate': { - '#text': x509, + '#text': provider.publicKey.replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''), }, }, }, @@ -167,7 +129,7 @@ export class SAMLIdentifyProviderService { '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', 'ds:X509Data': { 'ds:X509Certificate': { - '#text': x509, + '#text': provider.publicKey.replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''), }, }, }, diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 549438f61b..c401f0210e 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -12,6 +12,10 @@ export default defineComponent({ modelValue: { required: false, }, + disabled: { + type: Boolean, + default: false, + }, }, setup(props, context) { const value = ref(props.modelValue); @@ -41,6 +45,7 @@ export default defineComponent({ key: option.key as string, value: option.props?.value, modelValue: value.value, + disabled: props.disabled, 'onUpdate:modelValue': _v => value.value = _v, }, () => option.children)), ), diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 2944930f61..99e2a3239d 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -204,9 +204,9 @@ SPDX-License-Identifier: AGPL-3.0-only - + - +