Skip to content

Commit ce2496f

Browse files
committed
fix(sso): parse the SAML encryption certificate as X.509
Comparing derived public keys accepted a private key PEM in the certificate field, since a private key satisfies that comparison — and the metadata document was then built by stripping the PEM armor off whatever was pasted, which would have published the private key as the service provider certificate. The certificate is now parsed with X509Certificate, which rejects key PEMs outright, and the document carries that parsed certificate's own DER bytes rather than re-serialized input.
1 parent 37dfa41 commit ce2496f

2 files changed

Lines changed: 76 additions & 35 deletions

File tree

‎apps/sim/app/api/auth/sso/register/route.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { execFileSync } from 'node:child_process'
6+
import { X509Certificate } from 'node:crypto'
67
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
78
import { tmpdir } from 'node:os'
89
import path from 'node:path'
@@ -452,6 +453,27 @@ describe('POST /api/auth/sso/register', () => {
452453
expect(samlConfig.spMetadata.metadata).not.toContain('PRIVATE KEY')
453454
})
454455

456+
it('publishes only the certificate bytes, never the key, in the metadata', async () => {
457+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
458+
queueProviders([])
459+
460+
await POST(
461+
request(
462+
samlBody({ encryptAssertions: true, spEncryptionCert: SP_CERT, spDecryptionKey: SP_KEY })
463+
)
464+
)
465+
466+
const { samlConfig } = mockRegisterSSOProvider.mock.calls[0][0].body
467+
const published = samlConfig.spMetadata.metadata
468+
/** Built from the parsed certificate's own DER, so it cannot echo pasted input. */
469+
expect(published).toContain(new X509Certificate(SP_CERT).raw.toString('base64'))
470+
expect(published).not.toContain(
471+
SP_KEY.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, '')
472+
.replace(/\s+/g, '')
473+
.slice(0, 40)
474+
)
475+
})
476+
455477
it('never writes the private key to a log line', async () => {
456478
queueMembers([{ organizationId: 'org1', role: 'owner' }])
457479
queueProviders([])
@@ -503,6 +525,19 @@ describe('POST /api/auth/sso/register', () => {
503525
{ spEncryptionCert: 'not-a-cert', spDecryptionKey: SP_KEY },
504526
],
505527
['a private key that is not PEM', { spEncryptionCert: SP_CERT, spDecryptionKey: 'nope' }],
528+
[
529+
'a key pair whose halves do not match',
530+
{ spEncryptionCert: SP_CERT, spDecryptionKey: OTHER.key },
531+
],
532+
/**
533+
* A private key satisfies a public-key comparison, so anything short of
534+
* X.509 parsing would accept it here and then publish it as the
535+
* certificate in service provider metadata.
536+
*/
537+
[
538+
'a private key pasted into the certificate field',
539+
{ spEncryptionCert: SP_KEY, spDecryptionKey: SP_KEY },
540+
],
506541
])('refuses %s', async (_label, overrides) => {
507542
queueMembers([{ organizationId: 'org1', role: 'owner' }])
508543
queueProviders([])

‎apps/sim/app/api/auth/sso/register/route.ts‎

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createPrivateKey, createPublicKey } from 'node:crypto'
1+
import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto'
22
import { db, member, ssoDomain, ssoProvider } from '@sim/db'
33
import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider'
44
import { createLogger } from '@sim/logger'
@@ -85,48 +85,53 @@ async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise<Discove
8585
}
8686
}
8787

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()
9491
}
9592

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 }
10194

10295
/**
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.
107107
*/
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
113110
try {
114-
certificatePublicKey = publicKeyOf(cert ?? '', 'certificate')
111+
certificate = new X509Certificate(cert ?? '')
115112
} 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+
}
117117
}
118118

119119
let privateKeyPublicKey: string
120120
try {
121-
privateKeyPublicKey = publicKeyOf(privateKey ?? '', 'private key')
121+
privateKeyPublicKey = publicKeyOfPrivateKey(privateKey ?? '')
122122
} 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+
}
124127
}
125128

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' }
128132
}
129-
return null
133+
134+
return { certificate }
130135
}
131136

132137
/** The stored decryption key of a SAML config, when it holds one. */
@@ -614,9 +619,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
614619
decryptionKey = storedKey
615620
}
616621

622+
let encryptionCertificate: X509Certificate | null = null
617623
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
620627
}
621628

622629
const computedCallbackUrl =
@@ -645,11 +652,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
645652
* the certificate goes in the document; the matching private key stays in
646653
* the provider row, encrypted.
647654
*/
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+
: ''
653659

654660
const spMetadataXml = `<?xml version="1.0" encoding="UTF-8"?>
655661
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(getBaseUrl())}">

0 commit comments

Comments
 (0)