import { DOMParser } from '@xmldom/xmldom';import utility, { flattenDeep, isString } from './utility';import { algorithms, wording, namespace } from './urn';import { select } from 'xpath';import { MetadataInterface } from './metadata';import nrsa, { SigningSchemeHash } from 'node-rsa';import { SignedXml, FileKeyInfo } from 'xml-crypto';import * as xmlenc from '@authenio/xml-encryption';import { extract } from './extractor';import camelCase from 'camelcase';import { getContext } from './api';
const signatureAlgorithms = algorithms.signature;const digestAlgorithms = algorithms.digest;const certUse = wording.certUse;const urlParams = wording.urlParams;const dom = DOMParser;
export interface SignatureConstructor { rawSamlMessage: string; referenceTagXPath?: string; privateKey: string; privateKeyPass?: string; signatureAlgorithm: string; signingCert: string | Buffer; isBase64Output?: boolean; signatureConfig?: any; isMessageSigned?: boolean; transformationAlgorithms?: string[];}
export interface SignatureVerifierOptions { metadata?: MetadataInterface; keyFile?: string; signatureAlgorithm?: string;}
export interface ExtractorResult { [key: string]: any; signature?: string | string[]; issuer?: string | string[]; nameID?: string; notexist?: boolean;}
export interface LoginResponseAttribute { name: string; nameFormat: string; valueXsiType: string; valueTag: string; valueXmlnsXs?: string; valueXmlnsXsi?: string;}
export interface LoginResponseAdditionalTemplates { attributeStatementTemplate?: AttributeStatementTemplate; attributeTemplate?: AttributeTemplate;}
export interface BaseSamlTemplate { context: string;}
export interface LoginResponseTemplate extends BaseSamlTemplate { attributes?: LoginResponseAttribute[]; additionalTemplates?: LoginResponseAdditionalTemplates;}export interface AttributeStatementTemplate extends BaseSamlTemplate { }
export interface AttributeTemplate extends BaseSamlTemplate { }
export interface LoginRequestTemplate extends BaseSamlTemplate { }
export interface LogoutRequestTemplate extends BaseSamlTemplate { }
export interface LogoutResponseTemplate extends BaseSamlTemplate { }
export type KeyUse = 'signing' | 'encryption';
export interface KeyComponent { [key: string]: any;}
export interface LibSamlInterface { getQueryParamByType: (type: string) => string; createXPath: (local, isExtractAll?: boolean) => string; replaceTagsByValue: (rawXML: string, tagValues: any) => string; attributeStatementBuilder: (attributes: LoginResponseAttribute[], attributeTemplate: AttributeTemplate, attributeStatementTemplate: AttributeStatementTemplate) => string; constructSAMLSignature: (opts: SignatureConstructor) => string; verifySignature: (xml: string, opts) => [boolean, any]; createKeySection: (use: KeyUse, cert: string | Buffer) => {}; constructMessageSignature: (octetString: string, key: string, passphrase?: string, isBase64?: boolean, signingAlgorithm?: string) => string; verifyMessageSignature: (metadata, octetString: string, signature: string | Buffer, verifyAlgorithm?: string) => boolean; getKeyInfo: (x509Certificate: string, signatureConfig?: any) => void; encryptAssertion: (sourceEntity, targetEntity, entireXML: string) => Promise<string>; decryptAssertion: (here, entireXML: string) => Promise<[string, any]>;
getSigningScheme: (sigAlg: string) => string | null; getDigestMethod: (sigAlg: string) => string | null;
nrsaAliasMapping: any; defaultLoginRequestTemplate: LoginRequestTemplate; defaultLoginResponseTemplate: LoginResponseTemplate; defaultAttributeStatementTemplate: AttributeStatementTemplate; defaultAttributeTemplate: AttributeTemplate; defaultLogoutRequestTemplate: LogoutRequestTemplate; defaultLogoutResponseTemplate: LogoutResponseTemplate;}
const libSaml = () => {
function getQueryParamByType(type: string) { if ([urlParams.logoutRequest, urlParams.samlRequest].indexOf(type) !== -1) { return 'SAMLRequest'; } if ([urlParams.logoutResponse, urlParams.samlResponse].indexOf(type) !== -1) { return 'SAMLResponse'; } throw new Error('ERR_UNDEFINED_QUERY_PARAMS'); } const nrsaAliasMapping = { 'http://www.w3.org/2000/09/xmldsig#rsa-sha1': 'pkcs1-sha1', 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256': 'pkcs1-sha256', 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512': 'pkcs1-sha512', }; const defaultLoginRequestTemplate = { context: '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" AssertionConsumerServiceURL="{AssertionConsumerServiceURL}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:NameIDPolicy Format="{NameIDFormat}" AllowCreate="{AllowCreate}"/></samlp:AuthnRequest>', }; const defaultLogoutRequestTemplate = { context: '<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}"><saml:Issuer>{Issuer}</saml:Issuer><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID></samlp:LogoutRequest>', };
const defaultAttributeStatementTemplate = { context: '<saml:AttributeStatement>{Attributes}</saml:AttributeStatement>', };
const defaultAttributeTemplate = { context: '<saml:Attribute Name="{Name}" NameFormat="{NameFormat}"><saml:AttributeValue xmlns:xs="{ValueXmlnsXs}" xmlns:xsi="{ValueXmlnsXsi}" xsi:type="{ValueXsiType}">{Value}</saml:AttributeValue></saml:Attribute>', };
const defaultLoginResponseTemplate = { context: '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="{StatusCode}"/></samlp:Status><saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}"><saml:Issuer>{Issuer}</saml:Issuer><saml:Subject><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}"><saml:AudienceRestriction><saml:Audience>{Audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions>{AuthnStatement}{AttributeStatement}</saml:Assertion></samlp:Response>', attributes: [], additionalTemplates: { "attributeStatementTemplate": defaultAttributeStatementTemplate, "attributeTemplate": defaultAttributeTemplate } }; const defaultLogoutResponseTemplate = { context: '<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="{StatusCode}"/></samlp:Status></samlp:LogoutResponse>', }; function getSigningScheme(sigAlg?: string): SigningSchemeHash { if (sigAlg) { const algAlias = nrsaAliasMapping[sigAlg]; if (!(algAlias === undefined)) { return algAlias; } } return nrsaAliasMapping[signatureAlgorithms.RSA_SHA1]; } function getDigestMethod(sigAlg: string): string | null { const digestAlg = digestAlgorithms[sigAlg]; if (!(digestAlg === undefined)) { return digestAlg; } return null; } function createXPath(local, isExtractAll?: boolean): string { if (isString(local)) { return isExtractAll === true ? "//*[local-name(.)='" + local + "']/text()" : "//*[local-name(.)='" + local + "']"; } return "//*[local-name(.)='" + local.name + "']/@" + local.attr; }
function tagging(prefix: string, content: string): string { const camelContent = camelCase(content); return prefix + camelContent.charAt(0).toUpperCase() + camelContent.slice(1); }
return {
createXPath, getQueryParamByType, defaultLoginRequestTemplate, defaultLoginResponseTemplate, defaultAttributeStatementTemplate, defaultAttributeTemplate, defaultLogoutRequestTemplate, defaultLogoutResponseTemplate,
replaceTagsByValue(rawXML: string, tagValues: any): string { Object.keys(tagValues).forEach(t => { rawXML = rawXML.replace(new RegExp(`{${t}}`, 'g'), tagValues[t]); }); return rawXML; }, attributeStatementBuilder( attributes: LoginResponseAttribute[], attributeTemplate: AttributeTemplate = defaultAttributeTemplate, attributeStatementTemplate: AttributeStatementTemplate = defaultAttributeStatementTemplate ): string { const attr = attributes.map(({ name, nameFormat, valueTag, valueXsiType, valueXmlnsXs, valueXmlnsXsi }) => { const defaultValueXmlnsXs = 'http://www.w3.org/2001/XMLSchema'; const defaultValueXmlnsXsi = 'http://www.w3.org/2001/XMLSchema-instance'; let attributeLine = attributeTemplate.context; attributeLine = attributeLine.replace('{Name}', name); attributeLine = attributeLine.replace('{NameFormat}', nameFormat); attributeLine = attributeLine.replace('{ValueXmlnsXs}', valueXmlnsXs ? valueXmlnsXs : defaultValueXmlnsXs); attributeLine = attributeLine.replace('{ValueXmlnsXsi}', valueXmlnsXsi ? valueXmlnsXsi : defaultValueXmlnsXsi); attributeLine = attributeLine.replace('{ValueXsiType}', valueXsiType); attributeLine = attributeLine.replace('{Value}', `{${tagging('attr', valueTag)}}`); return attributeLine; }).join(''); return attributeStatementTemplate.context.replace('{Attributes}', attr); },
constructSAMLSignature(opts: SignatureConstructor) { const { rawSamlMessage, referenceTagXPath, privateKey, privateKeyPass, signatureAlgorithm = signatureAlgorithms.RSA_SHA256, transformationAlgorithms = [ 'http://www.w3.org/2000/09/xmldsig#enveloped-signature', 'http://www.w3.org/2001/10/xml-exc-c14n#', ], signingCert, signatureConfig, isBase64Output = true, isMessageSigned = false, } = opts; const sig = new SignedXml(); if (referenceTagXPath) { sig.addReference( referenceTagXPath, transformationAlgorithms, getDigestMethod(signatureAlgorithm) ); } if (isMessageSigned) { sig.addReference( '/*', transformationAlgorithms, getDigestMethod(signatureAlgorithm), '', '', '', false, ); } sig.signatureAlgorithm = signatureAlgorithm; sig.keyInfoProvider = new this.getKeyInfo(signingCert, signatureConfig); sig.signingKey = utility.readPrivateKey(privateKey, privateKeyPass, true); if (signatureConfig) { sig.computeSignature(rawSamlMessage, signatureConfig); } else { sig.computeSignature(rawSamlMessage); } return isBase64Output !== false ? utility.base64Encode(sig.getSignedXml()) : sig.getSignedXml(); }, verifySignature(xml: string, opts: SignatureVerifierOptions) {
const doc = new dom().parseFromString(xml); const messageSignatureXpath = "/*[contains(local-name(), 'Response') or contains(local-name(), 'Request')]/*[local-name(.)='Signature']"; const assertionSignatureXpath = "/*[contains(local-name(), 'Response') or contains(local-name(), 'Request')]/*[local-name(.)='Assertion']/*[local-name(.)='Signature']"; const wrappingElementsXPath = "/*[contains(local-name(), 'Response')]/*[local-name(.)='Assertion']/*[local-name(.)='Subject']/*[local-name(.)='SubjectConfirmation']/*[local-name(.)='SubjectConfirmationData']//*[local-name(.)='Assertion' or local-name(.)='Signature']";
let selection: any = []; let assertionNode: string | null = null; const messageSignatureNode = select(messageSignatureXpath, doc); const assertionSignatureNode = select(assertionSignatureXpath, doc); const wrappingElementNode = select(wrappingElementsXPath, doc);
selection = selection.concat(messageSignatureNode); selection = selection.concat(assertionSignatureNode);
if (wrappingElementNode.length !== 0) { throw new Error('ERR_POTENTIAL_WRAPPING_ATTACK'); }
if (selection.length === 0) { throw new Error('ERR_ZERO_SIGNATURE'); }
const sig = new SignedXml(); let verified = true; selection.forEach(signatureNode => {
sig.signatureAlgorithm = opts.signatureAlgorithm;
if (!opts.keyFile && !opts.metadata) { throw new Error('ERR_UNDEFINED_SIGNATURE_VERIFIER_OPTIONS'); }
if (opts.keyFile) { sig.keyInfoProvider = new FileKeyInfo(opts.keyFile); }
if (opts.metadata) {
const certificateNode = select(".//*[local-name(.)='X509Certificate']", signatureNode) as any; let metadataCert: any = opts.metadata.getX509Certificate(certUse.signing); if (Array.isArray(metadataCert)) { metadataCert = flattenDeep(metadataCert); } else if (typeof metadataCert === 'string') { metadataCert = [metadataCert]; } metadataCert = metadataCert.map(utility.normalizeCerString);
if (certificateNode.length === 0 && metadataCert.length === 0) { throw new Error('NO_SELECTED_CERTIFICATE'); }
if (certificateNode.length !== 0) { const x509CertificateData = certificateNode[0].firstChild.data; const x509Certificate = utility.normalizeCerString(x509CertificateData);
if ( metadataCert.length >= 1 && !metadataCert.find(cert => cert.trim() === x509Certificate.trim()) ) { throw new Error('ERROR_UNMATCH_CERTIFICATE_DECLARATION_IN_METADATA'); }
sig.keyInfoProvider = new this.getKeyInfo(x509Certificate);
} else { sig.keyInfoProvider = new this.getKeyInfo(metadataCert[0]); }
}
sig.loadSignature(signatureNode);
doc.removeChild(signatureNode);
verified = verified && sig.checkSignature(doc.toString());
if (!verified) { throw new Error('ERR_FAILED_TO_VERIFY_SIGNATURE'); }
});
if (messageSignatureNode.length === 1) { const node = select("/*[contains(local-name(), 'Response') or contains(local-name(), 'Request')]/*[local-name(.)='Assertion']", doc); if (node.length === 1) { assertionNode = node[0].toString(); } }
if (assertionSignatureNode.length === 1) { const verifiedAssertionInfo = extract(assertionSignatureNode[0].toString(), [{ key: 'refURI', localPath: ['Signature', 'SignedInfo', 'Reference'], attributes: ['URI'] }]); const desiredAssertionInfo = extract(doc.toString(), [{ key: 'id', localPath: ['~Response', 'Assertion'], attributes: ['ID'] }]); if (verifiedAssertionInfo.refURI !== `#${desiredAssertionInfo.id}`) { throw new Error('ERR_POTENTIAL_WRAPPING_ATTACK'); } const verifiedDoc = extract(doc.toString(), [{ key: 'assertion', localPath: ['~Response', 'Assertion'], attributes: [], context: true }]); assertionNode = verifiedDoc.assertion.toString(); }
return [verified, assertionNode]; }, createKeySection(use: KeyUse, certString: string | Buffer): KeyComponent { return { ['KeyDescriptor']: [ { _attr: { use }, }, { ['ds:KeyInfo']: [ { _attr: { 'xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', }, }, { ['ds:X509Data']: [{ 'ds:X509Certificate': utility.normalizeCerString(certString), }], }, ], }], }; }, constructMessageSignature( octetString: string, key: string, passphrase?: string, isBase64?: boolean, signingAlgorithm?: string ) { const decryptedKey = new nrsa( utility.readPrivateKey(key, passphrase), 'private', { signingScheme: getSigningScheme(signingAlgorithm), } ); const signature = decryptedKey.sign(octetString); return isBase64 !== false ? signature.toString('base64') : signature; }, verifyMessageSignature( metadata, octetString: string, signature: string | Buffer, verifyAlgorithm?: string ) { const signCert = metadata.getX509Certificate(certUse.signing); const signingScheme = getSigningScheme(verifyAlgorithm); const key = new nrsa(utility.getPublicKeyPemFromCertificate(signCert), 'public', { signingScheme }); return key.verify(Buffer.from(octetString), Buffer.from(signature)); }, getKeyInfo(x509Certificate: string, signatureConfig: any = {}) { this.getKeyInfo = key => { const prefix = signatureConfig.prefix ? `${signatureConfig.prefix}:` : ''; return `<${prefix}X509Data><${prefix}X509Certificate>${x509Certificate}</${prefix}X509Certificate></${prefix}X509Data>`; }; this.getKey = keyInfo => { return utility.getPublicKeyPemFromCertificate(x509Certificate).toString(); }; }, encryptAssertion(sourceEntity, targetEntity, xml?: string) { return new Promise<string>((resolve, reject) => {
if (!xml) { return reject(new Error('ERR_UNDEFINED_ASSERTION')); }
const sourceEntitySetting = sourceEntity.entitySetting; const targetEntityMetadata = targetEntity.entityMeta; const doc = new dom().parseFromString(xml); const assertions = select("//*[local-name(.)='Assertion']", doc) as Node[]; if (!Array.isArray(assertions)) { throw new Error('ERR_NO_ASSERTION'); } if (assertions.length !== 1) { throw new Error('ERR_MULTIPLE_ASSERTION'); }
if (sourceEntitySetting.isAssertionEncrypted) {
const publicKeyPem = utility.getPublicKeyPemFromCertificate(targetEntityMetadata.getX509Certificate(certUse.encrypt));
xmlenc.encrypt(assertions[0].toString(), { rsa_pub: Buffer.from(publicKeyPem), pem: Buffer.from(`-----BEGIN CERTIFICATE-----${targetEntityMetadata.getX509Certificate(certUse.encrypt)}-----END CERTIFICATE-----`), encryptionAlgorithm: sourceEntitySetting.dataEncryptionAlgorithm, keyEncryptionAlgorithm: sourceEntitySetting.keyEncryptionAlgorithm, }, (err, res) => { if (err) { console.error(err); return reject(new Error('ERR_EXCEPTION_OF_ASSERTION_ENCRYPTION')); } if (!res) { return reject(new Error('ERR_UNDEFINED_ENCRYPTED_ASSERTION')); } const { encryptedAssertion: encAssertionPrefix } = sourceEntitySetting.tagPrefix; const encryptAssertionNode = new dom().parseFromString(`<${encAssertionPrefix}:EncryptedAssertion xmlns:${encAssertionPrefix}="${namespace.names.assertion}">${res}</${encAssertionPrefix}:EncryptedAssertion>`); doc.replaceChild(encryptAssertionNode, assertions[0]); return resolve(utility.base64Encode(doc.toString())); }); } else { return resolve(utility.base64Encode(xml)); } }); }, decryptAssertion(here, entireXML: string) { return new Promise<[string, any]>((resolve, reject) => { if (!entireXML) { return reject(new Error('ERR_UNDEFINED_ASSERTION')); } const hereSetting = here.entitySetting; const xml = new dom().parseFromString(entireXML); const encryptedAssertions = select("/*[contains(local-name(), 'Response')]/*[local-name(.)='EncryptedAssertion']", xml) as Node[]; if (!Array.isArray(encryptedAssertions)) { throw new Error('ERR_UNDEFINED_ENCRYPTED_ASSERTION'); } if (encryptedAssertions.length !== 1) { throw new Error('ERR_MULTIPLE_ASSERTION'); } return xmlenc.decrypt(encryptedAssertions[0].toString(), { key: utility.readPrivateKey(hereSetting.encPrivateKey, hereSetting.encPrivateKeyPass), }, (err, res) => { if (err) { console.error(err); return reject(new Error('ERR_EXCEPTION_OF_ASSERTION_DECRYPTION')); } if (!res) { return reject(new Error('ERR_UNDEFINED_ENCRYPTED_ASSERTION')); } const assertionNode = new dom().parseFromString(res); xml.replaceChild(assertionNode, encryptedAssertions[0]); return resolve([xml.toString(), res]); }); }); }, async isValidXml(input: string) {
const { validate } = getContext();
if (!validate) {
return Promise.reject('Your application is potentially vulnerable because no validation function found. Please read the documentation on how to setup the validator. (https://github.com/tngan/samlify#installation)');
}
try { return await validate(input); } catch (e) { throw e; }
}, };};
export default libSaml();