/* ================================================================== * HMACHandler.java - 16/06/2015 9:33:08 am * * Copyright 2007-2015 SolarNetwork.net Dev Team * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA * 02111-1307 USA * ================================================================== */ package ocpp.v15.support; import java.io.UnsupportedEncodingException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Collections; import java.util.Date; import java.util.Iterator; import java.util.Set; import java.util.TimeZone; import java.util.regex.Pattern; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.xml.namespace.QName; import javax.xml.soap.SOAPBody; import javax.xml.soap.SOAPElement; import javax.xml.soap.SOAPException; import javax.xml.soap.SOAPHeader; import javax.xml.soap.SOAPHeaderElement; import javax.xml.soap.SOAPMessage; import javax.xml.soap.Text; import javax.xml.ws.handler.MessageContext; import javax.xml.ws.handler.soap.SOAPHandler; import javax.xml.ws.handler.soap.SOAPMessageContext; import org.apache.commons.codec.binary.Base64; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * {@link SOAPHandler} to generate a HMAC-SHA256 signature of a SOAP message. * * <p> * This handler is designed to be integrated into both the client and server * side of an OCPP system. Both sides must be configured to use the same * {@code secret} value, which is the key used to generate a HMAC-SHA256 hash. * The Base64-encoded hash is added as the content of a new SOAP header element, * {@code Authentication}, and the system's reported current time is added as a * {@code ts} attribute to that element. For example: * </p> * * <pre> * <Authentication xmlns="urn://SolarNetwork/SolarNode/WS" * ts="2015-01-01T12:00:00.000Z">doEIdjlsdkfjsopdifjso==</Authentication> * </pre> * * <p> * The encrypted HMAC message content is derived from the SOAP message itself, * and includes the following content, all delimited by a newline character ( * {@code \n}): * </p> * * <ol> * <li>The OCPP {@code chargePointIdentity} SOAP header value, or an empty * string if not available.</li> * <li>The current date, in ISO 8601 format in the UTC time zone.</li> * <li>Top-level SOAP header elements, in DOM order. In addition, for any * top-level element in the {@code http://www.w3.org/2005/08/addressing} * namespace (WS-Addressing) then the first of any child {@code Address} element * is included. This is to ensure all WS-Addressing values are included in the * digest.</li> * <li>Recursive SOAP body elements, in DOM order, including the SOAP body * element itself.</li> * </ol> * * <p> * For any SOAP element to be included in the digest, the syntax of the value to * add is <code>{uri}localName=value</code> where <code>uri</code> is the URI of * the namespace of the element, <code>localName</code> is the element name, and * <code>value</code> is the normalized text value of the element (normalized by * calling {@link org.w3c.dom.Node.normalize()}). If the text value is only * whitespace, however, the <code>=value</code> part is omitted. * </p> * * <p> * For example, a SOAP message like this: * </p> * * <pre> * <S:Envelope xmlns:S="http://www.w3.org/2003/05/soap-envelope"> <S:Header> <chargeBoxIdentity xmlns="urn://Ocpp/Cs/2012/06/">UID=1013,O=SolarDev</chargeBoxIdentity> <To xmlns="http://www.w3.org/2005/08/addressing" >http://localhost:9000/steve/services/CentralSystemService</To> <Action xmlns="http://www.w3.org/2005/08/addressing">/BootNotification</Action> <ReplyTo xmlns="http://www.w3.org/2005/08/addressing"> <Address>http://www.w3.org/2005/08/addressing/anonymous</Address> </ReplyTo> <MessageID xmlns="http://www.w3.org/2005/08/addressing" >uuid:f86a3b23-5db3-4260-ab21-d72348da5ecc</MessageID> <From xmlns="http://www.w3.org/2005/08/addressing"> <Address>http://192.168.1.44:8680/ocpp/v15</Address> </From> </S:Header> <S:Body> <bootNotificationRequest xmlns="urn://Ocpp/Cs/2012/06/"> <chargePointVendor>SolarNetwork</chargePointVendor> <chargePointModel>SolarNode</chargePointModel> <chargePointSerialNumber>155</chargePointSerialNumber> <firmwareVersion>0.1.0</firmwareVersion> </bootNotificationRequest> </S:Body> </S:Envelope> * </pre> * * <p> * would result in a canonical digest value like this: * </p> * * <pre> * UID=1013,O=SolarDev 2015-06-16T06:31:13.492Z {urn://Ocpp/Cs/2012/06/}chargeBoxIdentity=UID=1013,O=SolarDev {http://www.w3.org/2005/08/addressing}To=http://localhost:9000/steve/services/CentralSystemService {http://www.w3.org/2005/08/addressing}Action=/BootNotification {http://www.w3.org/2005/08/addressing}ReplyTo=http://www.w3.org/2005/08/addressing/anonymous {http://www.w3.org/2005/08/addressing}MessageID=uuid:f86a3b23-5db3-4260-ab21-d72348da5ecc {http://www.w3.org/2005/08/addressing}From=http://192.168.1.44:8680/ocpp/v15 {http://www.w3.org/2003/05/soap-envelope}Body {urn://Ocpp/Cs/2012/06/}bootNotificationRequest {urn://Ocpp/Cs/2012/06/}chargePointVendor=SolarNetwork {urn://Ocpp/Cs/2012/06/}chargePointModel=SolarNode {urn://Ocpp/Cs/2012/06/}chargePointSerialNumber=155 {urn://Ocpp/Cs/2012/06/}firmwareVersion=0.1.0 * </pre> * * <p> * The {@link #getMaximumTimeSkew()} value represents the maximum amount of time * difference allowed between the system's reported current time and the * * @author matt * @version 1.2 */ public class HMACHandler implements SOAPHandler<SOAPMessageContext> { public static final String SN_WS_NS = "urn://SolarNetwork/SolarNode/WS"; public static final QName SN_WS_AUTH = new QName(SN_WS_NS, "Authentication"); public static final String SN_WS_TIMESTAMP = "ts"; public static final QName OCPP_CS_CHARGE_BOX_IDENTITY = new QName("urn://Ocpp/Cs/2012/06/", "chargeBoxIdentity"); public static final QName OCPP_CP_CHARGE_BOX_IDENTITY = new QName("urn://Ocpp/Cp/2012/06/", "chargeBoxIdentity"); public static final String DEFAULT_SECRET = "changeit"; private static final Pattern NON_WHITESPACE = Pattern.compile("\\S"); private String secret = DEFAULT_SECRET; private boolean required = true; private Mac hmac; private long maximumTimeSkew = 5 * 60 * 1000L; // 5 minutes private final Logger log = LoggerFactory.getLogger(getClass()); @Override public boolean handleMessage(SOAPMessageContext context) { Boolean outboundProperty = (Boolean) context.get(MessageContext.MESSAGE_OUTBOUND_PROPERTY); if ( outboundProperty != null && outboundProperty.booleanValue() ) { try { addAuthenticationHeader(context); } catch ( SOAPException e ) { log.error("Error adding Authentication SOAP header", e); } } else { try { validateAuthenticationHeader(context); } catch ( SOAPException e ) { log.error("Error validating Authentication SOAP header", e); } } return true; } private Mac getHMAC() { Mac m = hmac; if ( m == null ) { try { m = Mac.getInstance("HmacSHA256"); SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "HmacSHA256"); m.init(key); hmac = m; } catch ( NoSuchAlgorithmException e ) { throw new RuntimeException(e); } catch ( InvalidKeyException e ) { throw new RuntimeException(e); } } return m; } private DateFormat getTimestampDateFormat() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); sdf.setTimeZone(TimeZone.getTimeZone("UTC")); return sdf; } private String getTimestampString(long time) { DateFormat sdf = getTimestampDateFormat(); return sdf.format(new Date(time)); } private void addAuthenticationHeader(SOAPMessageContext context) throws SOAPException { addAuthenticationHeader(context, System.currentTimeMillis()); } private void addAuthenticationHeader(SOAPMessageContext context, final long date) throws SOAPException { SOAPMessage msg = context.getMessage(); SOAPHeader head = msg.getSOAPHeader(); SOAPElement auth = getAuthenticationHeader(head); String hashData = calculateHashData(context, date); String hash = hash(hashData); if ( auth == null ) { auth = head.addHeaderElement(SN_WS_AUTH); } auth.setTextContent(hash); if ( !auth.hasAttribute(SN_WS_TIMESTAMP) ) { auth.setAttribute(SN_WS_TIMESTAMP, getTimestampString(date)); } } private String calculateHashData(final SOAPMessageContext context, final long date) throws SOAPException { SOAPMessage msg = context.getMessage(); SOAPHeader head = msg.getSOAPHeader(); SOAPBody body = msg.getSOAPBody(); // our hash will be constructed out of the date, the headers, and the body final StringBuilder buf = new StringBuilder("\n"); // leading newline to handle chargeBoxIdentity value later final String ts = getTimestampString(date); buf.append(ts).append('\n'); String cbIdent = null; for ( @SuppressWarnings("unchecked") Iterator<SOAPHeaderElement> itr = head.examineAllHeaderElements(); itr.hasNext(); ) { SOAPHeaderElement header = itr.next(); QName headerName = header.getElementQName(); String hashKey = headerName.toString(); // e.g. {nsURL}localName String hashValue = ""; if ( WSAddressingFromHandler.WSA_NS.equals(headerName.getNamespaceURI()) ) { // if this element has a child Address element, use that content, otherwise this node's content SOAPElement addr = null; for ( @SuppressWarnings("unchecked") Iterator<SOAPElement> children = header .getChildElements(WSAddressingFromHandler.WSA_ADDRESS); children.hasNext(); ) { addr = children.next(); break; } if ( addr != null ) { addr.normalize(); hashValue = addr.getTextContent(); } else { header.normalize(); hashValue = header.getTextContent(); } } else if ( SN_WS_AUTH.equals(headerName) ) { continue; } else { header.normalize(); hashValue = header.getTextContent(); } if ( cbIdent == null && (OCPP_CS_CHARGE_BOX_IDENTITY.equals(headerName) || OCPP_CP_CHARGE_BOX_IDENTITY.equals(headerName)) ) { cbIdent = hashValue; } buf.append(hashKey).append('=').append(hashValue).append('\n'); } appendHashData(body, buf); if ( cbIdent != null ) { buf.insert(0, cbIdent); } return buf.toString(); } private SOAPElement getAuthenticationHeader(SOAPHeader head) throws SOAPException { SOAPElement auth = null; for ( Iterator<?> itr = head.getChildElements(SN_WS_AUTH); itr.hasNext(); ) { auth = (SOAPElement) itr.next(); break; } return auth; } private void validateAuthenticationHeader(SOAPMessageContext context) throws SOAPException { SOAPMessage msg = context.getMessage(); SOAPHeader head = msg.getSOAPHeader(); SOAPElement auth = getAuthenticationHeader(head); if ( auth == null ) { // TODO: configurable property to 1) ignore or 2) throw exception. Now just ignore. return; } Date timestamp = null; try { timestamp = (Date) getTimestampDateFormat().parseObject(auth.getAttribute(SN_WS_TIMESTAMP)); } catch ( ParseException e ) { throw new RuntimeException("Invalid date: " + e.getMessage()); } final long skew = Math.abs(timestamp.getTime() - System.currentTimeMillis()); if ( skew > maximumTimeSkew ) { throw new RuntimeException("Time skew too big: " + skew); } final String calculatedHashData = calculateHashData(context, timestamp.getTime()); final String calculatedHash = hash(calculatedHashData); final String presentedHash = auth.getTextContent(); if ( !calculatedHash.equals(presentedHash) ) { throw new RuntimeException("Invalid Authentication value"); } } private String hash(String data) { log.debug("HMAC hash input:\n{}", data); Mac mac = getHMAC(); byte[] hash; synchronized ( mac ) { mac.reset(); hash = mac.doFinal(data.getBytes()); } try { return new String(Base64.encodeBase64(hash, false), "US-ASCII"); } catch ( UnsupportedEncodingException e ) { // should never get here throw new RuntimeException(e); } } private void appendHashData(SOAPElement root, StringBuilder buf) { QName name = root.getElementQName(); boolean first = true; for ( Iterator<?> itr = root.getChildElements(); itr.hasNext(); ) { Object o = itr.next(); if ( o instanceof SOAPElement ) { if ( first ) { buf.append(name.toString()).append('\n'); first = false; } appendHashData((SOAPElement) o, buf); } else if ( o instanceof Text ) { Text t = (Text) o; if ( t.isComment() ) { continue; } t.normalize(); // only append non-whitespace text if ( NON_WHITESPACE.matcher(t.getTextContent()).find() ) { buf.append(name.toString()).append('=').append(t.getTextContent()).append('\n'); } } } } @Override public boolean handleFault(SOAPMessageContext context) { return true; } @Override public void close(MessageContext context) { // nadda } @Override public Set<QName> getHeaders() { return Collections.singleton(SN_WS_AUTH); } /** * Set the shared secret value. * * This value must be shared with the OCPP central system. * * @param secret * The secret value to use. If <em>null</em>, an empty string will be * used. */ public void setSecret(String secret) { if ( secret == null ) { secret = ""; } this.secret = secret; hmac = null; } /** * Set the maximum allowed time skew, in milliseconds. * * The {@code ts} attribute of incoming messages will be compared to the * current system time, and if it differs by more than this amount the * message will be rejected. In order for this check to be effective, the * system's clock must be kept accurate, for example by using a service like * NTP or GPS to synchronize the system's clock. * * @param maximumTimeSkew * The maximum time skew allowed. */ public void setMaximumTimeSkew(long maximumTimeSkew) { this.maximumTimeSkew = maximumTimeSkew; } /** * Get the maximum allowed time skew. * * @return The configured maximum time skew, in milliseconds. */ public long getMaximumTimeSkew() { return maximumTimeSkew; } /** * Get the required flag. * * @return The configured required flag value. Defaults to <em>true</em>. */ public boolean isRequired() { return required; } /** * Set the required flag. * * If <em>true</em> then an authentication header is required to be present * (and valid) or else an exception will be throw. If <em>false</em> then a * missing authentication header will not cause any exception to be thrown, * but if provided will still be validated and if not valid an exception * will still be thrown. * * @param required * The required flag value to set. */ public void setRequired(boolean required) { this.required = required; } }