/* * Copyright (C) 2011 Citrix Systems, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.cloud.bridge.util; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.security.SignatureException; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Collection; import java.util.Iterator; import java.util.TreeMap; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.apache.commons.codec.binary.Base64; import org.apache.log4j.Logger; public class EC2RestAuth { protected final static Logger logger = Logger.getLogger(RestAuth.class); // TreeMap: used to Sort the UTF-8 query string components by parameter name with natural byte ordering protected TreeMap<String, String> queryParts = null; // used to generate a CanonicalizedQueryString protected String canonicalizedQueryString = null; protected String hostHeader = null; protected String httpRequestURI = null; public EC2RestAuth() { // these must be lexicographically sorted queryParts = new TreeMap<String, String>(); } public static Calendar parseDateString( String created ) { DateFormat formatter = null; Calendar cal = Calendar.getInstance(); // -> for some unknown reason SimpleDateFormat does not properly handle the 'Z' timezone if (created.endsWith( "Z" )) created = created.replace( "Z", "+0000" ); try { formatter = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ssz" ); cal.setTime( formatter.parse( created )); return cal; } catch( Exception e ) {} try { formatter = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ssZ" ); cal.setTime( formatter.parse( created )); return cal; } catch( Exception e ) {} // -> the time zone is GMT if not defined try { formatter = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ss" ); cal.setTime( formatter.parse( created )); created = created + "+0000"; formatter = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ssZ" ); cal.setTime( formatter.parse( created )); return cal; } catch( Exception e ) {} // -> including millseconds? try { formatter = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ss.Sz" ); cal.setTime( formatter.parse( created )); return cal; } catch( Exception e ) {} try { formatter = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ss.SZ" ); cal.setTime( formatter.parse( created )); return cal; } catch( Exception e ) {} // -> the CloudStack API used to return this format for some calls try { formatter = new SimpleDateFormat( "EEE MMM dd HH:mm:ss z yyyy" ); cal.setTime( formatter.parse( created )); return cal; } catch( Exception e ) {} return null; } /** * Assuming that a port number is to be included. * * @param header - contents of the "Host:" header, skipping the 'Host:' preamble. */ public void setHostHeader( String hostHeader ) { if ( null == hostHeader ) this.hostHeader = null; else this.hostHeader = hostHeader.trim().toLowerCase(); } public void setHTTPRequestURI( String uri ) { if ( null == uri || 0 == uri.length()) this.httpRequestURI = new String( "/" ); else this.httpRequestURI = uri.trim(); } /** * The given query string needs to be pulled apart, sorted by paramter name, and reconstructed. * We sort the query string values via a TreeMap. * * @param query - this string still has all URL encoding in place. */ public void setQueryString( String query ) { String parameter = null; if (null == query) { this.canonicalizedQueryString = null; return; } // -> sort by paramter name String[] parts = query.split( "&" ); if (null != parts) { for( int i=0; i < parts.length; i++ ) { parameter = parts[i]; if (parameter.startsWith( "?" )) parameter = parameter.substring( 1 ); // -> don't include a 'Signature=' parameter if (parameter.startsWith( "Signature=")) continue; int offset = parameter.indexOf( "=" ); if ( -1 == offset ) queryParts.put( parameter, parameter + "=" ); else queryParts.put( parameter.substring( 0, offset ), parameter ); } } // -> reconstruct into a canonicalized format Collection<String> headers = queryParts.values(); Iterator<String> itr = headers.iterator(); StringBuffer reconstruct = new StringBuffer(); int count = 0; while( itr.hasNext()) { if (0 < count) reconstruct.append( "&" ); reconstruct.append( itr.next()); count++; } canonicalizedQueryString = reconstruct.toString(); } /** * The request is authenticated if we can regenerate the same signature given * on the request. Before calling this function make sure to set the header values * defined by the public values above. * * @param httpVerb - the type of HTTP request (e.g., GET, PUT) * @param secretKey - value obtained from the AWSAccessKeyId * @param signature - the signature we are trying to recreate, note can be URL-encoded * @param method - { "HmacSHA1", "HmacSHA256" } * * @throws SignatureException * * @return true if request has been authenticated, false otherwise * @throws UnsupportedEncodingException */ public boolean verifySignature( String httpVerb, String secretKey, String signature, String method ) throws SignatureException, UnsupportedEncodingException { if (null == httpVerb || null == secretKey || null == signature) return false; httpVerb = httpVerb.trim(); secretKey = secretKey.trim(); signature = signature.trim(); // -> first calculate the StringToSign after the caller has initialized all the header values String StringToSign = genStringToSign( httpVerb ); String calSig = calculateRFC2104HMAC( StringToSign, secretKey, method.equalsIgnoreCase( "HmacSHA1" )); // -> the passed in signature is defined to be URL encoded? (and it must be base64 encoded) int offset = signature.indexOf( "%" ); if (-1 != offset) signature = URLDecoder.decode( signature, "UTF-8" ); boolean match = signature.equals( calSig ); if (!match) logger.error( "Signature mismatch, [" + signature + "] [" + calSig + "] over [" + StringToSign + "]" ); return match; } /** * This function generates the single string that will be used to sign with a users * secret key. * * StringToSign = HTTP-Verb + "\n" + * ValueOfHostHeaderInLowercase + "\n" + * HTTPRequestURI + "\n" + * CanonicalizedQueryString * * @return The single StringToSign or null. */ private String genStringToSign( String httpVerb ) { StringBuffer stringToSign = new StringBuffer(); stringToSign.append( httpVerb ).append( "\n" ); if (null != this.hostHeader) stringToSign.append( this.hostHeader ); stringToSign.append( "\n" ); if (null != this.httpRequestURI) stringToSign.append( this.httpRequestURI ); stringToSign.append( "\n" ); if (null != this.canonicalizedQueryString) stringToSign.append( this.canonicalizedQueryString ); if (0 == stringToSign.length()) return null; else return stringToSign.toString(); } /** * Create a signature by the following method: * new String( Base64( SHA1 or SHA256 ( key, byte array ))) * * @param signIt - the data to generate a keyed HMAC over * @param secretKey - the user's unique key for the HMAC operation * @param useSHA1 - if false use SHA256 * @return String - the recalculated string * @throws SignatureException */ private String calculateRFC2104HMAC( String signIt, String secretKey, boolean useSHA1 ) throws SignatureException { SecretKeySpec key = null; Mac hmacShaAlg = null; String result = null; try { if ( useSHA1 ) { key = new SecretKeySpec( secretKey.getBytes(), "HmacSHA1" ); hmacShaAlg = Mac.getInstance( "HmacSHA1" ); } else { key = new SecretKeySpec( secretKey.getBytes(), "HmacSHA256" ); hmacShaAlg = Mac.getInstance( "HmacSHA256" ); } hmacShaAlg.init( key ); byte [] rawHmac = hmacShaAlg.doFinal( signIt.getBytes()); result = new String( Base64.encodeBase64( rawHmac )); } catch( Exception e ) { throw new SignatureException( "Failed to generate keyed HMAC on REST request: " + e.getMessage()); } return result.trim(); } }