com.apigee.callout.httpsignature.SignatureVerifierCallout.java Source code

Java tutorial

Introduction

Here is the source code for com.apigee.callout.httpsignature.SignatureVerifierCallout.java

Source

// SignatureVerifierCallout.java
//
// A callout for Apigee Edge that verifies an HTTP Signature.
// See http://tools.ietf.org/html/draft-cavage-http-signatures-04 .
//
// Thursday, 20 August 2015, 09:45
//
//
// This software is licensed under the MIT License (MIT)
//
// Copyright (c) 2015 by Dino Chiesa, Apigee Corporation
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//

package com.apigee.callout.httpsignature;

import com.apigee.flow.execution.ExecutionContext;
import com.apigee.flow.execution.ExecutionResult;
import com.apigee.flow.execution.IOIntensive;
import com.apigee.flow.execution.spi.Execution;
import com.apigee.flow.message.MessageContext;
import com.apigee.flow.message.Message;

import java.io.InputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;

import java.util.Date;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.ArrayList;

// for RSA
import java.security.spec.X509EncodedKeySpec;
import java.security.spec.InvalidKeySpecException;
import java.security.PublicKey;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;

import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.exception.ExceptionUtils;
import org.apache.http.client.utils.DateUtils;

import com.apigee.callout.httpsignature.HttpSignature;

@IOIntensive
public class SignatureVerifierCallout implements Execution {

    private Map properties; // read-only

    public SignatureVerifierCallout(Map properties) {
        this.properties = properties;
    }

    private String getRequiredAlgorithm(MessageContext msgCtxt) throws Exception {
        String algorithm = (String) this.properties.get("algorithm");
        if (algorithm == null) {
            // to prevent attacks, ALWAYS require an algorithm
            throw new IllegalStateException("algorithm is not specified.");
        }
        algorithm = algorithm.trim();
        if (algorithm.equals("")) {
            // to prevent attacks, ALWAYS require an algorithm
            throw new IllegalStateException("algorithm is not specified.");
        }

        algorithm = resolvePropertyValue(algorithm, msgCtxt);
        if (algorithm == null || algorithm.equals("")) {
            throw new IllegalStateException("algorithm resolves to an empty string.");
        }
        return algorithm;
    }

    private long getMaxTimeSkew(MessageContext msgCtxt) throws Exception {
        final long defaultMaxSkew = 60L;
        String timeskew = (String) this.properties.get("maxtimeskew");
        if (timeskew == null) {
            return defaultMaxSkew;
        }
        timeskew = timeskew.trim();
        if (timeskew.equals("")) {
            return defaultMaxSkew;
        }
        timeskew = resolvePropertyValue(timeskew, msgCtxt);
        if (timeskew == null || timeskew.equals("")) {
            return defaultMaxSkew;
        }
        return Long.parseLong(timeskew, 10);
    }

    private String[] getRequiredHeaders(MessageContext msgCtxt)
    /* throws Exception */ {
        String headers = (String) this.properties.get("headers");
        if (headers == null) {
            return null;
        }
        headers = headers.trim();
        if (headers.equals("")) {
            return null;
        }
        headers = resolvePropertyValue(headers, msgCtxt);
        if (headers == null || headers.equals("")) {
            // Uncomment the below to force configuration to require a
            // headers property
            //throw new IllegalStateException("headers resolves to an empty string");
            return null;
        }
        return StringUtils.split(headers, " ");
    }

    private long getRequestSecondsSinceEpoch(MessageContext msgCtxt)
    /* throws DateParseException */ {
        String dateString = msgCtxt.getVariable("request.header.date"); // Date header
        if (dateString == null) {
            return (new Date()).getTime() / 1000L;
        } // now
        dateString = dateString.trim();
        if (dateString.equals("")) {
            return (new Date()).getTime() / 1000L;
        } // now
        Date d1 = DateUtils.parseDate(dateString);
        long unixTime = d1.getTime() / 1000;
        return unixTime; // seconds since epoch
    }

    private String getMultivaluedHeader(MessageContext msgCtxt, String header) {
        String name = header + ".values";
        String separator = ",";
        ArrayList list = (ArrayList) msgCtxt.getVariable(name);
        String result = "";
        for (Object s : list) {
            result += (String) s + separator;
        }
        return result;
    }

    private HttpSignature getFullSignature(MessageContext msgCtxt) throws IllegalStateException {
        // consult the property first. If it is not present, retrieve the
        // request header.
        String signature = ((String) this.properties.get("fullsignature"));
        Boolean obtainedFromHeader = false;
        if (signature == null) {
            // In draft 01, the header was "Authorization".
            // As of draft 03, the header is "Signature".
            // NB: In Edge, getting a header that includes a comma requires getting
            // the .values, which is an ArrayList of strings.
            //
            signature = getMultivaluedHeader(msgCtxt, "request.header.signature");

            obtainedFromHeader = true;
            if (signature == null) {
                throw new IllegalStateException("signature is not specified.");
            }
        }
        signature = signature.trim();
        if (signature.equals("")) {
            throw new IllegalStateException("fullsignature is empty.");
        }
        if (!obtainedFromHeader) {
            signature = resolvePropertyValue(signature, msgCtxt);
            if (signature == null || signature.equals("")) {
                throw new IllegalStateException("fullsignature does not resolve.");
            }
        }
        HttpSignature httpsig = new HttpSignature(signature);
        return httpsig;
    }

    private static InputStream getResourceAsStream(String resourceName) throws IOException {
        // forcibly prepend a slash
        if (!resourceName.startsWith("/")) {
            resourceName = "/" + resourceName;
        }
        if (!resourceName.startsWith("/resources")) {
            resourceName = "/resources" + resourceName;
        }
        InputStream in = SignatureVerifierCallout.class.getResourceAsStream(resourceName);

        if (in == null) {
            throw new IOException("resource \"" + resourceName + "\" not found");
        }

        return in;
    }

    // If the value of a property value begins and ends with curlies,
    // and has no intervening spaces, eg, {apiproxy.name}, then
    // "resolve" the value by de-referencing the context variable whose
    // name appears between the curlies.
    private String resolvePropertyValue(String spec, MessageContext msgCtxt) {
        if (spec.startsWith("{") && spec.endsWith("}") && (spec.indexOf(" ") == -1)) {
            String varname = spec.substring(1, spec.length() - 1);
            String value = msgCtxt.getVariable(varname);
            return value;
        }
        return spec;
    }

    private class KeyProviderImpl implements KeyProvider {
        MessageContext c;
        private final static String specialValue = "(request-target)";

        public KeyProviderImpl(MessageContext msgCtxt) {
            c = msgCtxt;
        }

        public String getSecretKey() throws IllegalStateException {
            String key = (String) properties.get("secret-key");
            if (key == null || key.equals("")) {
                throw new IllegalStateException("configuration error: secret-key is not specified or is empty.");
            }
            key = resolvePropertyValue(key, c);
            if (key == null || key.equals("")) {
                throw new IllegalStateException("configuration error: secret-key is null or empty.");
            }
            return key;
        }

        public PublicKey getPublicKey()
                throws IOException, NoSuchAlgorithmException, InvalidKeySpecException, CertificateException {
            String publicKeyString = (String) properties.get("public-key");

            // There are various ways to specify the public key.

            // Try "public-key"
            if (publicKeyString != null) {
                if (publicKeyString.equals("")) {
                    throw new IllegalStateException("public-key must be non-empty");
                }
                publicKeyString = resolvePropertyValue(publicKeyString, c);

                if (publicKeyString == null || publicKeyString.equals("")) {
                    throw new IllegalStateException(
                            "public-key variable resolves to empty; invalid when algorithm is RS*");
                }
                PublicKey key = KeyUtils.publicKeyStringToPublicKey(publicKeyString);
                if (key == null) {
                    throw new InvalidKeySpecException("must be PKCS#1 or PKCS#8");
                }
                return key;
            }

            // // Try "modulus" + "exponent"
            // String modulus = (String) this.properties.get("modulus");
            // String exponent = (String) this.properties.get("exponent");
            //
            // if ((modulus != null) && (exponent != null)) {
            //     modulus = resolvePropertyValue(modulus, msgCtxt);
            //     exponent = resolvePropertyValue(exponent, msgCtxt);
            //
            //     if (modulus==null || modulus.equals("") ||
            //         exponent==null || exponent.equals("")) {
            //         throw new IllegalStateException("modulus or exponent resolves to empty; invalid when algorithm is RS*");
            //     }
            //
            //     PublicKey key = KeyUtils.pubKeyFromModulusAndExponent(modulus, exponent);
            //     return key;
            // }

            // Try certificate
            String certString = (String) properties.get("certificate");
            if (certString != null) {
                if (certString.equals("")) {
                    throw new IllegalStateException("certificate must be non-empty");
                }
                certString = resolvePropertyValue(certString, c);
                //msgCtxt.setVariable("jwt_certstring", certString);
                if (certString == null || certString.equals("")) {
                    throw new IllegalStateException(
                            "certificate variable resolves to empty; invalid when algorithm is RS*");
                }
                PublicKey key = KeyUtils.certStringToPublicKey(certString);
                if (key == null) {
                    throw new InvalidKeySpecException("invalid certificate format");
                }
                return key;
            }

            // last chance
            String pemfile = (String) properties.get("pemfile");
            if (pemfile == null || pemfile.equals("")) {
                throw new IllegalStateException(
                        "must specify pemfile or public-key or certificate when algorithm is RS*");
            }
            pemfile = resolvePropertyValue(pemfile, c);
            //msgCtxt.setVariable("jwt_pemfile", pemfile);
            if (pemfile == null || pemfile.equals("")) {
                throw new IllegalStateException("pemfile resolves to nothing; invalid when algorithm is RS*");
            }

            InputStream in = getResourceAsStream(pemfile);
            byte[] keyBytes = new byte[in.available()];
            in.read(keyBytes);
            in.close();
            publicKeyString = new String(keyBytes, "UTF-8");

            // allow pemfile resolution as Certificate or Public Key
            PublicKey key = KeyUtils.pemFileStringToPublicKey(publicKeyString);
            if (key == null) {
                throw new InvalidKeySpecException("invalid pemfile format");
            }
            return key;
        }

    }

    private class EdgeHeaderProvider implements ReadOnlyHttpSigHeaderMap {
        MessageContext c;
        private final static String specialValue = "(request-target)";

        public EdgeHeaderProvider(MessageContext msgCtxt) {
            c = msgCtxt;
        }

        public String getHeaderValue(String header) {
            String value = null;

            if (header.equals(specialValue)) {
                try {
                    // in HTTP Signature, the "path" includes the url path + query
                    URI uri = new URI(c.getVariable("proxy.url").toString());

                    String path = uri.getPath();
                    if (uri.getQuery() != null) {
                        path += "?" + uri.getQuery();
                    }

                    value = c.getVariable("request.verb");
                    if (value == null || value.equals(""))
                        value = "unknown verb";
                    value = value.toLowerCase() + " " + path;

                } catch (URISyntaxException exc1) {
                    value = "none";
                }
            } else {
                value = c.getVariable("request.header." + header);
            }
            return value;
        }
    }

    public ExecutionResult execute(MessageContext msgCtxt, ExecutionContext exeCtxt) {
        String varName;
        String varprefix = "httpsig";
        ExecutionResult result = ExecutionResult.ABORT;
        Boolean isValid = false;
        try {
            varName = varprefix + "_error";
            msgCtxt.setVariable(varName, null);

            // 1. retrieve and parse the full signature header payload
            HttpSignature sigObject = getFullSignature(msgCtxt);

            // 2. get the required algorithm, if specified,
            // and check that the actual algorithm in the sig is as required.
            String actualAlgorithm = sigObject.getAlgorithm();
            String requiredAlgorithm = getRequiredAlgorithm(msgCtxt);

            varName = varprefix + "_requiredAlgorithm";
            msgCtxt.setVariable(varName, requiredAlgorithm);
            if (!HttpSignature.supportedAlgorithms.containsKey(requiredAlgorithm)) {
                throw new Exception("unsupported algorithm: " + requiredAlgorithm);
            }

            if (!actualAlgorithm.equals(requiredAlgorithm)) {
                throw new Exception("algorithm used in signature (" + actualAlgorithm + ") is not as required ("
                        + requiredAlgorithm + ")");
            }

            // 3. if there are any headers that are configured to be required,
            // check that they are all present in the sig.
            String[] requiredHeaders = getRequiredHeaders(msgCtxt);
            if (requiredHeaders != null) {
                varName = varprefix + "_requiredHeaders";
                msgCtxt.setVariable(varName, StringUtils.join(requiredHeaders, " "));
                String[] actualHeaders = sigObject.getHeaders();
                int i;
                for (i = 0; i < actualHeaders.length; i++) {
                    actualHeaders[i] = actualHeaders[i].toLowerCase();
                }
                for (i = 0; i < requiredHeaders.length; i++) {
                    String h = requiredHeaders[i].toLowerCase();
                    if (ArrayUtils.indexOf(actualHeaders, h) < 0) {
                        throw new Exception("signature is missing required header (" + h + ").");
                    }
                }
            }

            // 4. Verify that the date skew is within compliance
            long maxTimeSkew = getMaxTimeSkew(msgCtxt);
            if (maxTimeSkew > 0L) {
                long t1 = getRequestSecondsSinceEpoch(msgCtxt);
                long t2 = (new Date()).getTime() / 1000; // seconds since epoch
                long diff = Math.abs(t2 - t1);
                varName = varprefix + "_timeskew";
                msgCtxt.setVariable(varName, Long.toString(diff));
                if (diff > maxTimeSkew) {
                    // fail.
                    throw new Exception("date header exceeds max time skew (" + diff + ">" + maxTimeSkew + ").");
                }
            }

            // 5. finally, verify the signature
            EdgeHeaderProvider hp = new EdgeHeaderProvider(msgCtxt);
            KeyProvider kp = new KeyProviderImpl(msgCtxt);
            SigVerificationResult verification = sigObject.verify(actualAlgorithm, hp, kp);
            isValid = verification.isValid;
            varName = varprefix + "_signingBase";
            msgCtxt.setVariable(varName, verification.signingBase.replace('\n', '|'));

            result = ExecutionResult.SUCCESS;
        } catch (Exception e) {
            //e.printStackTrace();
            varName = varprefix + "_error";
            msgCtxt.setVariable(varName, e.getMessage());
            varName = varprefix + "_stacktrace";
            msgCtxt.setVariable(varName, ExceptionUtils.getStackTrace(e));
            result = ExecutionResult.ABORT;
        }

        varName = varprefix + "_isValid";
        msgCtxt.setVariable(varName, isValid);
        return result;
    }
}