|
1 | | -import { createPrivateKey, createPublicKey } from 'node:crypto' |
| 1 | +import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto' |
2 | 2 | import { db, member, ssoDomain, ssoProvider } from '@sim/db' |
3 | 3 | import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider' |
4 | 4 | import { createLogger } from '@sim/logger' |
@@ -85,48 +85,53 @@ async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise<Discove |
85 | 85 | } |
86 | 86 | } |
87 | 87 |
|
88 | | -/** The base64 body of a PEM document, which is what SAML metadata carries. */ |
89 | | -function stripPemArmor(pem: string): string { |
90 | | - return pem |
91 | | - .replace(/-----(BEGIN|END)[^-]+-----/g, '') |
92 | | - .replace(/\s+/g, '') |
93 | | - .trim() |
| 88 | +/** The SubjectPublicKeyInfo of a private key, for comparing it with a certificate's. */ |
| 89 | +function publicKeyOfPrivateKey(pem: string): string { |
| 90 | + return createPublicKey(createPrivateKey(pem)).export({ type: 'spki', format: 'pem' }).toString() |
94 | 91 | } |
95 | 92 |
|
96 | | -/** The SubjectPublicKeyInfo of a certificate or private key, for comparing the two. */ |
97 | | -function publicKeyOf(pem: string, kind: 'certificate' | 'private key'): string { |
98 | | - const key = kind === 'certificate' ? createPublicKey(pem) : createPublicKey(createPrivateKey(pem)) |
99 | | - return key.export({ type: 'spki', format: 'pem' }).toString() |
100 | | -} |
| 93 | +type KeyPairCheck = { error: string } | { certificate: X509Certificate } |
101 | 94 |
|
102 | 95 | /** |
103 | | - * Names the first problem with an encryption key pair, or null when both parse |
104 | | - * and belong together. A mismatched pair is the failure worth catching here: |
105 | | - * each half is individually valid, so nothing complains until the identity |
106 | | - * provider encrypts an assertion Sim cannot read, weeks later at sign-in. |
| 96 | + * Parses an encryption key pair, or names the first problem with it. |
| 97 | + * |
| 98 | + * The certificate is parsed as X.509 rather than as "any key material": a |
| 99 | + * private key PEM would otherwise satisfy a public-key comparison, and the |
| 100 | + * metadata document would then publish that private key as the service |
| 101 | + * provider's certificate. The parsed certificate is returned so the document is |
| 102 | + * built from its own DER bytes rather than from re-serialized input. |
| 103 | + * |
| 104 | + * A mismatched pair is the other failure worth catching here — each half is |
| 105 | + * individually valid, so nothing complains until the identity provider encrypts |
| 106 | + * an assertion Sim cannot read. |
107 | 107 | */ |
108 | | -function describeKeyPairProblem( |
109 | | - cert: string | undefined, |
110 | | - privateKey: string | undefined |
111 | | -): string | null { |
112 | | - let certificatePublicKey: string |
| 108 | +function checkKeyPair(cert: string | undefined, privateKey: string | undefined): KeyPairCheck { |
| 109 | + let certificate: X509Certificate |
113 | 110 | try { |
114 | | - certificatePublicKey = publicKeyOf(cert ?? '', 'certificate') |
| 111 | + certificate = new X509Certificate(cert ?? '') |
115 | 112 | } catch { |
116 | | - return 'Service provider certificate must be a PEM X.509 certificate beginning with -----BEGIN CERTIFICATE-----' |
| 113 | + return { |
| 114 | + error: |
| 115 | + 'Service provider certificate must be a PEM X.509 certificate beginning with -----BEGIN CERTIFICATE-----', |
| 116 | + } |
117 | 117 | } |
118 | 118 |
|
119 | 119 | let privateKeyPublicKey: string |
120 | 120 | try { |
121 | | - privateKeyPublicKey = publicKeyOf(privateKey ?? '', 'private key') |
| 121 | + privateKeyPublicKey = publicKeyOfPrivateKey(privateKey ?? '') |
122 | 122 | } catch { |
123 | | - return 'Service provider private key must be a PEM private key beginning with -----BEGIN PRIVATE KEY-----' |
| 123 | + return { |
| 124 | + error: |
| 125 | + 'Service provider private key must be a PEM private key beginning with -----BEGIN PRIVATE KEY-----', |
| 126 | + } |
124 | 127 | } |
125 | 128 |
|
126 | | - if (certificatePublicKey !== privateKeyPublicKey) { |
127 | | - return 'Service provider certificate and private key are not a matching pair' |
| 129 | + const certificatePublicKey = certificate.publicKey.export({ type: 'spki', format: 'pem' }) |
| 130 | + if (certificatePublicKey.toString() !== privateKeyPublicKey) { |
| 131 | + return { error: 'Service provider certificate and private key are not a matching pair' } |
128 | 132 | } |
129 | | - return null |
| 133 | + |
| 134 | + return { certificate } |
130 | 135 | } |
131 | 136 |
|
132 | 137 | /** The stored decryption key of a SAML config, when it holds one. */ |
@@ -614,9 +619,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { |
614 | 619 | decryptionKey = storedKey |
615 | 620 | } |
616 | 621 |
|
| 622 | + let encryptionCertificate: X509Certificate | null = null |
617 | 623 | if (encryptAssertions) { |
618 | | - const keyPairProblem = describeKeyPairProblem(spEncryptionCert, decryptionKey) |
619 | | - if (keyPairProblem) return NextResponse.json({ error: keyPairProblem }, { status: 400 }) |
| 624 | + const keyPair = checkKeyPair(spEncryptionCert, decryptionKey) |
| 625 | + if ('error' in keyPair) return NextResponse.json({ error: keyPair.error }, { status: 400 }) |
| 626 | + encryptionCertificate = keyPair.certificate |
620 | 627 | } |
621 | 628 |
|
622 | 629 | const computedCallbackUrl = |
@@ -645,11 +652,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { |
645 | 652 | * the certificate goes in the document; the matching private key stays in |
646 | 653 | * the provider row, encrypted. |
647 | 654 | */ |
648 | | - const encryptionKeyDescriptor = |
649 | | - encryptAssertions && spEncryptionCert |
650 | | - ? ` |
651 | | - <md:KeyDescriptor use="encryption"><ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>${escapeXml(stripPemArmor(spEncryptionCert))}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor>` |
652 | | - : '' |
| 655 | + const encryptionKeyDescriptor = encryptionCertificate |
| 656 | + ? ` |
| 657 | + <md:KeyDescriptor use="encryption"><ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>${encryptionCertificate.raw.toString('base64')}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor>` |
| 658 | + : '' |
653 | 659 |
|
654 | 660 | const spMetadataXml = `<?xml version="1.0" encoding="UTF-8"?> |
655 | 661 | <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(getBaseUrl())}"> |
|
0 commit comments