fix(SSO): SAMLのメタデータに使われる証明書を保存するように

This commit is contained in:
まっちゃとーにゅ 2024-03-18 01:19:16 +09:00
parent fa4db2c420
commit 29e8fe419f
No known key found for this signature in database
GPG Key ID: 6AFBBF529601C1DB
6 changed files with 85 additions and 52 deletions

View File

@ -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<string> {
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);
}

View File

@ -1,10 +1,12 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as jose from 'jose'; 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 { 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 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'; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
@ -108,6 +110,8 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.singleSignOnServiceProviderRepository) @Inject(DI.singleSignOnServiceProviderRepository)
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository, private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
@ -125,16 +129,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})) }))
: { publicKey: ps.secret ?? randomUUID(), privateKey: null }; : { 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({ const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.insert({
id: randomUUID(), id: randomUUID(),
createdAt: new Date(), createdAt: now,
name: ps.name ? ps.name : null, name: ps.name ? ps.name : null,
type: ps.type, type: ps.type,
issuer: ps.issuer, issuer: ps.issuer,
audience: ps.audience?.filter(i => i.length > 0) ?? [], audience: ps.audience?.filter(i => i.length > 0) ?? [],
binding: ps.binding, binding: ps.binding,
acsUrl: ps.acsUrl, acsUrl: ps.acsUrl,
publicKey: publicKey, publicKey: ps.type === 'saml' && ps.useCertificate ? x509Cert : publicKey,
privateKey: privateKey, privateKey: privateKey,
signatureAlgorithm: ps.signatureAlgorithm, signatureAlgorithm: ps.signatureAlgorithm,
cipherAlgorithm: ps.cipherAlgorithm ? ps.cipherAlgorithm : null, cipherAlgorithm: ps.cipherAlgorithm ? ps.cipherAlgorithm : null,

View File

@ -1,9 +1,11 @@
import * as jose from 'jose';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js'; import * as jose from 'jose';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { genX509CertFromJWK } from '@/misc/gen-x509-cert-from-jwk.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
@ -44,6 +46,8 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.singleSignOnServiceProviderRepository) @Inject(DI.singleSignOnServiceProviderRepository)
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository, private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
@ -62,13 +66,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})) }))
: { publicKey: ps.secret ?? undefined, privateKey: undefined }; : { 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, { await this.singleSignOnServiceProviderRepository.update(service.id, {
name: ps.name !== '' ? ps.name : null, name: ps.name !== '' ? ps.name : null,
createdAt: service.type === 'saml' && ps.regenerateCertificate ? now : undefined,
issuer: ps.issuer, issuer: ps.issuer,
audience: ps.audience?.filter(i => i.length > 0), audience: ps.audience?.filter(i => i.length > 0),
binding: ps.binding, binding: ps.binding,
acsUrl: ps.acsUrl, acsUrl: ps.acsUrl,
publicKey: publicKey, publicKey: service.type === 'saml' && ps.regenerateCertificate ? x509Cert : publicKey,
privateKey: privateKey, privateKey: privateKey,
signatureAlgorithm: ps.signatureAlgorithm, signatureAlgorithm: ps.signatureAlgorithm,
cipherAlgorithm: ps.cipherAlgorithm !== '' ? ps.cipherAlgorithm : null, cipherAlgorithm: ps.cipherAlgorithm !== '' ? ps.cipherAlgorithm : null,

View File

@ -1,6 +1,5 @@
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import forge from 'node-forge';
import * as jose from 'jose'; import * as jose from 'jose';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import * as saml from 'samlify'; import * as saml from 'samlify';
@ -56,28 +55,10 @@ export class SAMLIdentifyProviderService {
public async createIdPMetadataXml( public async createIdPMetadataXml(
provider: MiSingleSignOnServiceProvider, provider: MiSingleSignOnServiceProvider,
): Promise<string> { ): Promise<string> {
const nowTime = new Date(); const tenYearsLaterTime = new Date(provider.createdAt.getTime());
const tenYearsLaterTime = new Date(nowTime.getTime());
tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10); tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10);
const tenYearsLater = tenYearsLaterTime.toISOString(); 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 = { const nodes = {
'md:EntityDescriptor': { 'md:EntityDescriptor': {
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata', '@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#', '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': { 'ds:X509Data': {
'ds:X509Certificate': { '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( public async createSPMetadataXml(
provider: MiSingleSignOnServiceProvider, provider: MiSingleSignOnServiceProvider,
): Promise<string> { ): Promise<string> {
const nowTime = new Date(); const tenYearsLaterTime = new Date(provider.createdAt.getTime());
const tenYearsLaterTime = new Date(nowTime.getTime());
tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10); tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10);
const tenYearsLater = tenYearsLaterTime.toISOString(); 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[] = [ const keyDescriptor: unknown[] = [
{ {
'@use': 'signing', '@use': 'signing',
@ -153,7 +115,7 @@ export class SAMLIdentifyProviderService {
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': { 'ds:X509Data': {
'ds:X509Certificate': { '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#', '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': { 'ds:X509Data': {
'ds:X509Certificate': { 'ds:X509Certificate': {
'#text': x509, '#text': provider.publicKey.replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''),
}, },
}, },
}, },

View File

@ -12,6 +12,10 @@ export default defineComponent({
modelValue: { modelValue: {
required: false, required: false,
}, },
disabled: {
type: Boolean,
default: false,
},
}, },
setup(props, context) { setup(props, context) {
const value = ref(props.modelValue); const value = ref(props.modelValue);
@ -41,6 +45,7 @@ export default defineComponent({
key: option.key as string, key: option.key as string,
value: option.props?.value, value: option.props?.value,
modelValue: value.value, modelValue: value.value,
disabled: props.disabled,
'onUpdate:modelValue': _v => value.value = _v, 'onUpdate:modelValue': _v => value.value = _v,
}, () => option.children)), }, () => option.children)),
), ),

View File

@ -204,9 +204,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="service.acsUrl"> <MkInput v-model="service.acsUrl">
<template #label>Assertion Consumer Service URL</template> <template #label>Assertion Consumer Service URL</template>
</MkInput> </MkInput>
<MkInput v-model="service.publicKey"> <MkTextarea v-model="service.publicKey">
<template #label>{{ service['useCertificate'] ? 'Public Key' : 'Secret' }}</template> <template #label>{{ service['useCertificate'] ? 'Public Key' : 'Secret' }}</template>
</MkInput> </MkTextarea>
<MkInput v-model="service.signatureAlgorithm"> <MkInput v-model="service.signatureAlgorithm">
<template #label>Signature Algorithm</template> <template #label>Signature Algorithm</template>
</MkInput> </MkInput>