digitalbazaar / forge

A native implementation of TLS in Javascript and tools to write crypto-based and network-heavy webapps
https://digitalbazaar.com/
Other
5.04k stars 778 forks source link

Support asynchronous key signing (and other methods) #861

Open dhensby opened 3 years ago

dhensby commented 3 years ago

I have some requirements to use external hosted Key Management Services; for example to produce a p7 signature of a JSON payload but with a hosted certificate.

At the moment, it's possible to pull down the certificate and construct the request, and with a bit of monkey-patching/hackery it's possible to actually perform an async key signing (see example below) - but it would be much nicer if this was officially supported so that keys could be more decoupled and externally hosted.


(async () => {
  const certPem = await getCertSomehow();

  const certificate = forge.pki.certificateFromPem(pem);
  const p7 = forge.pkcs7.createSignedData();
  p7.content = forge.util.createBuffer('data to sign');
  p7.addCertificate(certificate);

  // add as many signers as you like
  p7.addSigner({
    key: {
      sign: async (md) => {
        const signature = await getSignatureSomehow(md);
        return signature;
      },
      certificate,
      digestAlgorithm: forge.pki.oids.sha256,
      authenticatedAttributes: [
        {
          type: forge.pki.oids.contentType,
          value: forge.pki.oids.data,
        }, {
          type: forge.pki.oids.messageDigest,
          // value will be auto-populated at signing time
        }, {
          type: forge.pki.oids.signingTime,
          // value can also be auto-populated at signing time
          // We may also support passing this as an option to sign().
          // Would be useful to match the creation time of the document for example.
          value: new Date(),
        },
    ],
  });

  // perform the signing - ideally this would be async and we could just await this
  p7.sign({ detached: true });

  // walk through all the internally assigned promises we now need to wait to resolve
  p7.signerInfos = await Promise.all(p7.signerInfos.map(async (signerInfo) => {
    signerInfo.value = await Promise.all(signerInfo.value.map(async (value) => {
      value.value = await value.value;
      return value;
    }));
    return signerInfo;
  }));

  // just for completeness, assign resolved values to signer's signature
  p7.signers = await Promise.all(p7.signers.map(async (p7Signer) => {
    p7Signer.signature = await p7Signer.signature;
    return p7Signer;
  }));

  const raw = forge.asn1.toDer(p7.toAsn1()).getBytes();
})();
DavAslanyan commented 2 years ago

Hello @dhensby , I have a question, is 'md' hash of the file in the following section ?: key: { sign: async (md) => { const signature = await getSignatureSomehow(md); return signature; } and if yes, how to get base64 string from md?

dhensby commented 2 years ago

@DavAslanyan If you're looking at integrating what I've done like-for-like, the message digest is created by forge from the content for you automatically, so you don't really need to worry about what it is, exactly...

But yes, I believe it's the digest for the content you want to sign (ie: the file content, a string, whatever).

The md here is a forge md object. You have to digest it and then you can extract the digested hash in you preferred format:

const digest = md.digest(); // this returns a forge buffer
console.log(digest.getBytes()); // binary string, best used for converting to native buffer like so: `Buffer.from(digest.getBytes(), 'binary');`
console.log(digest.toHex()); // hex string

For base64 you need to do a bit more, personally I like using native buffers so I create a native buffer from the bytes then encode it as a string, but you can also use the util.encode64() function from this lib:

// native buffer approach
const base64 = Buffer.from(digest.getBytes(), 'binary').toString('base64');
// util approach
const base64 = forge.util.encode64(digest.getBytes());
dhensby commented 2 years ago

Oh, and the md object is important here because it's how you know what algorithm you're using to sign the data with (md.algoritm, I think), so if you're using an HSM or similar to sign the data, then you need more than just the digested data.

DavAslanyan commented 2 years ago

@dhensby thank you for your answer. I would like to sign a pdf using external service returning signature (Base64 signature of The hash).

sign: async (md) => { const signature = await getSignatureSomehow(md); return signature; }, The question is what kind of format should be returned signature?

And every time when I get this base64 string for the same file it is different, is it ok? const base64 = Buffer.from(digest.getBytes(), 'binary').toString('base64');

dhensby commented 2 years ago

Here is some rough code for how to do it with Azure:

const { DefaultAzureCredential } = require("@azure/identity");
const { CertificateClient } = require("@azure/keyvault-certificates");
const { KeyClient, CryptographyClient } = require("@azure/keyvault-keys");
const forge = require('node-forge');

const credential = new DefaultAzureCredential();

const vaultName = "<YOUR VAULT NAME>";
const url = `https://${vaultName}.vault.azure.net`;

const certClient = new CertificateClient(url, credential);
const keyClient = new KeyClient(url, credential);

const certificateName = "<YOUR CERTIFICATE NAME>";

/**
 * Convert the forge md and signing scheme to the Azure equivalent
 *
 * @param md
 * @param scheme
 * @returns {string}
 */
function forgeMdToAzureAlg(md, scheme) {
    const encoding = scheme === 'RSASSA-PKCS1-V1_5' ? 'RS' : 'PS';
    switch(md.algorithm) {
        case 'sha256':
            return `${encoding}256`;
        case 'sha384':
            return `${encoding}384`;
        case 'sha512':
            return `${encoding}512`;
        default:
            throw new Error('Unsupported algorithm');
    }
}

async function main() {
    // Fetch the certificate data from Key Vault
    const azCert = await certClient.getCertificate(certificateName);
    // fetch the certificate's key from the vault
    const keyId = new URL(azCert.keyId);
    const [, , keyName, keyVersion] = keyId.pathname.split('/');
    const key = await keyClient.getKey(keyName, { version: keyVersion });

    // construct a certificate PEM for Forge to work with
    const certPem = ['-----BEGIN CERTIFICATE-----', azCert.cer.toString('base64'), '-----END CERTIFICATE-----'].join('\n');

    // create the certificate and p7 signed data object
    const certificate = forge.pki.certificateFromPem(certPem);
    const p7 = forge.pkcs7.createSignedData();
    p7.content = forge.util.createBuffer('data to sign');
    p7.addCertificate(certificate);

    // add as many signers as you like
    p7.addSigner({
        key: {
            // an async signer, which uses the certificates key to sign the data
            sign: async (md, scheme) => {
                const cryptoClient = new CryptographyClient(key, credential);
                // convert the message digest object to the correct algorithm name for Azure and supply the digest as a buffer
                const signature = await cryptoClient.sign(forgeMdToAzureAlg(md, scheme), Buffer.from(md.digest().getBytes(), 'binary'));
                // return the binary string of the signature
                return signature.toString('binary');
            },
        },
        certificate,
        // this bit is important, you must choose a supported algorithm by the key vault
        // sha1 is not supported, for example
        digestAlgorithm: forge.pki.oids.sha256,
        authenticatedAttributes: [
            {
                type: forge.pki.oids.contentType,
                value: forge.pki.oids.data,
            }, {
                type: forge.pki.oids.messageDigest,
                // value will be auto-populated at signing time
            }, {
                type: forge.pki.oids.signingTime,
                // value can also be auto-populated at signing time
                // We may also support passing this as an option to sign().
                // Would be useful to match the creation time of the document for example.
                value: new Date(),
            },
        ],
    });

    // perform the signing - ideally this would be async and we could just await this
    p7.sign({ detached: true });

    // walk through all the internally assigned promises we now need to wait to resolve
    p7.signerInfos = await Promise.all(p7.signerInfos.map(async (signerInfo) => {
        signerInfo.value = await Promise.all(signerInfo.value.map(async (value) => {
            value.value = await value.value;
            return value;
        }));
        return signerInfo;
    }));

    // just for completeness, assign resolved values to signer's signature
    p7.signers = await Promise.all(p7.signers.map(async (p7Signer) => {
        p7Signer.signature = await p7Signer.signature;
        return p7Signer;
    }));

    // construct the raw signature
    const raw = forge.asn1.toDer(p7.toAsn1()).getBytes();

    // logs the signature as hex to the console - job done
    console.log(Buffer.from(raw, 'binary').toString('hex'));
}

main().catch(console.error);
dhensby commented 2 years ago

Oh, and @DavAslanyan - if you're looking to sign PDFs and you want to hit some kind of PAdES compliance, you're in for a world of pain, as forge just doesn't construct the signatures in a way that is compliant with PAdES.

You need to start fetching and integrating OCSP responses (not supported by this library) among other things that I struggled with.

In the end I had to throw out the forge library for signing PDFs and I still haven't managed to sign the PDF to be PAdES-LTV compliant (I can't seem to find a PDF library that can do this in node)

DavAslanyan commented 2 years ago

Thanks for your response, it really seems challenging.

funfuncode commented 1 year ago

Hello @dhensby. Did you find a way to sign a PDF document with LTV enabled?

dhensby commented 1 year ago

@funfuncode yes and no. I'm able to construct an LTV compliant CMS signature and attach it to a PDF, but the PDF standard then calls for the PDF to be signed again for it to show up in Adobe Acrobat as signed properly. As such, I've got PDFs with LTV signatures, but not ones that are validated by acrobat. I'm still looking for some help to do that 😓

othnielee commented 10 months ago

Here is some rough code for how to do it with Azure:

const { DefaultAzureCredential } = require("@azure/identity");
const { CertificateClient } = require("@azure/keyvault-certificates");
const { KeyClient, CryptographyClient } = require("@azure/keyvault-keys");
const forge = require('node-forge');

const credential = new DefaultAzureCredential();

const vaultName = "<YOUR VAULT NAME>";
const url = `https://${vaultName}.vault.azure.net`;

const certClient = new CertificateClient(url, credential);
const keyClient = new KeyClient(url, credential);

const certificateName = "<YOUR CERTIFICATE NAME>";

/**
 * Convert the forge md and signing scheme to the Azure equivalent
 *
 * @param md
 * @param scheme
 * @returns {string}
 */
function forgeMdToAzureAlg(md, scheme) {
    const encoding = scheme === 'RSASSA-PKCS1-V1_5' ? 'RS' : 'PS';
    switch(md.algorithm) {
        case 'sha256':
            return `${encoding}256`;
        case 'sha384':
            return `${encoding}384`;
        case 'sha512':
            return `${encoding}512`;
        default:
            throw new Error('Unsupported algorithm');
    }
}

async function main() {
    // Fetch the certificate data from Key Vault
    const azCert = await certClient.getCertificate(certificateName);
    // fetch the certificate's key from the vault
    const keyId = new URL(azCert.keyId);
    const [, , keyName, keyVersion] = keyId.pathname.split('/');
    const key = await keyClient.getKey(keyName, { version: keyVersion });

    // construct a certificate PEM for Forge to work with
    const certPem = ['-----BEGIN CERTIFICATE-----', azCert.cer.toString('base64'), '-----END CERTIFICATE-----'].join('\n');

    // create the certificate and p7 signed data object
    const certificate = forge.pki.certificateFromPem(certPem);
    const p7 = forge.pkcs7.createSignedData();
    p7.content = forge.util.createBuffer('data to sign');
    p7.addCertificate(certificate);

    // add as many signers as you like
    p7.addSigner({
        key: {
            // an async signer, which uses the certificates key to sign the data
            sign: async (md, scheme) => {
                const cryptoClient = new CryptographyClient(key, credential);
                // convert the message digest object to the correct algorithm name for Azure and supply the digest as a buffer
                const signature = await cryptoClient.sign(forgeMdToAzureAlg(md, scheme), Buffer.from(md.digest().getBytes(), 'binary'));
                // return the binary string of the signature
                return signature.toString('binary');
            },
        },
        certificate,
        // this bit is important, you must choose a supported algorithm by the key vault
        // sha1 is not supported, for example
        digestAlgorithm: forge.pki.oids.sha256,
        authenticatedAttributes: [
            {
                type: forge.pki.oids.contentType,
                value: forge.pki.oids.data,
            }, {
                type: forge.pki.oids.messageDigest,
                // value will be auto-populated at signing time
            }, {
                type: forge.pki.oids.signingTime,
                // value can also be auto-populated at signing time
                // We may also support passing this as an option to sign().
                // Would be useful to match the creation time of the document for example.
                value: new Date(),
            },
        ],
    });

    // perform the signing - ideally this would be async and we could just await this
    p7.sign({ detached: true });

    // walk through all the internally assigned promises we now need to wait to resolve
    p7.signerInfos = await Promise.all(p7.signerInfos.map(async (signerInfo) => {
        signerInfo.value = await Promise.all(signerInfo.value.map(async (value) => {
            value.value = await value.value;
            return value;
        }));
        return signerInfo;
    }));

    // just for completeness, assign resolved values to signer's signature
    p7.signers = await Promise.all(p7.signers.map(async (p7Signer) => {
        p7Signer.signature = await p7Signer.signature;
        return p7Signer;
    }));

    // construct the raw signature
    const raw = forge.asn1.toDer(p7.toAsn1()).getBytes();

    // logs the signature as hex to the console - job done
    console.log(Buffer.from(raw, 'binary').toString('hex'));
}

main().catch(console.error);

This was really helpful, @dhensby. The ideas here - especially the async signer and resolving signerInfos - got me past the roadblocks I had with Azure Key Vault signing. Put together a project with the learnings here: https://github.com/othnielee/pdf-sign. Thanks!!