OSCOREProfileRequestsGroupOSCORE.java

/*******************************************************************************
 * Copyright (c) 2019, RISE AB
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without 
 * modification, are permitted provided that the following conditions 
 * are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, 
 *    this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice, 
 *    this list of conditions and the following disclaimer in the documentation 
 *    and/or other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its
 *    contributors may be used to endorse or promote products derived from
 *    this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *******************************************************************************/
package se.sics.ace.coap.client;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.logging.Logger;

import org.eclipse.californium.core.CoapClient;
import org.eclipse.californium.core.CoapResponse;
import org.eclipse.californium.core.coap.CoAP.Code;
import org.eclipse.californium.core.coap.CoAP;
import org.eclipse.californium.core.coap.Request;
import org.eclipse.californium.core.coap.Response;
import org.eclipse.californium.core.network.CoapEndpoint;
import org.eclipse.californium.core.network.Endpoint;
import org.eclipse.californium.elements.exception.ConnectorException;
import org.eclipse.californium.oscore.CoapOSException;
import org.eclipse.californium.oscore.OSCoreCoapStackFactory;
import org.eclipse.californium.oscore.OSCoreCtx;
import org.eclipse.californium.oscore.OSCoreCtxDB;
import org.eclipse.californium.oscore.OSException;

import com.upokecenter.cbor.CBORException;
import com.upokecenter.cbor.CBORObject;
import com.upokecenter.cbor.CBORType;

import se.sics.ace.AceException;
import se.sics.ace.Constants;
import se.sics.ace.Util;
import se.sics.ace.coap.rs.oscoreProfile.OscoreSecurityContext;


/**
 * Implements getting a token from the /token endpoint for a client 
 * using the OSCORE profile.
 * 
 * Also implements POSTing the token to the /authz-info endpoint at the 
 * RS.
 * 
 * Clients are expected to create an instance of this class when the want to
 * perform token requests from a specific AS.
 * 
 * @author Marco Tiloca
 *
 */
public class OSCOREProfileRequestsGroupOSCORE {
    
    /**
     * The logger
     */
    private static final Logger LOGGER 
        = Logger.getLogger(OSCOREProfileRequests.class.getName() ); 

    /**
     * Sends a POST request to the /token endpoint of the AS to request an
     * access token.
     * 
     * @param asAddr  the full address of the /token endpoint
     *  (including scheme and hostname, and port if not default)
     * @param payload  the payload of the request.  Use the GetToken 
     *  class to construct this payload
     * @param ctx  the OSCORE context shared with the AS
     * 
     * @return  the response 
     *
     * @throws AceException 
     * @throws OSException 
     */
    public static Response getToken(String asAddr, CBORObject payload, 
            OSCoreCtx ctx, OSCoreCtxDB db) throws AceException, OSException {

        Request r = new Request(Code.POST);
        r.getOptions().setOscore(new byte[0]);
        r.setPayload(payload.EncodeToBytes());
        
        db.addContext(asAddr, ctx);
        
        CoapEndpoint.Builder builder = new CoapEndpoint.Builder();
        builder.setCoapStackFactory(new OSCoreCoapStackFactory());
        builder.setCustomCoapStackArgument(db);
        Endpoint clientEndpoint = builder.build();
        CoapClient client = new CoapClient(asAddr);
        client.setEndpoint(clientEndpoint);  
        try {
            return client.advanced(r).advanced();
        } catch (ConnectorException | IOException e) {
            LOGGER.severe("Connector error: " + e.getMessage());
            throw new AceException(e.getMessage());
        }
    }
    
    /**
     * Sends a POST request to the /authz-info endpoint of the RS to submit an
     * access token.
     * 
     * @param rsAddr  the full address of the /authz-info endpoint
     *  (including scheme and hostname, and port if not default)
     * @param asResp  the response from the AS containing the token
     *      and the access information
     * @param askForSignInfo  true when requesting information on the signature algorithm in the OSCORE group, false otherwise
     * @param db   the database of OSCORE Security Contexts
     * @param usedRecipientIds   the collection of already in use OSCORE Recipient IDs, it can be null when updating access rights
     * 
     * @return  the response 
     *
     * @throws AceException 
     * @throws OSException 
     */
    public static Response postToken(String rsAddr, Response asResp, boolean askForSignInfo, boolean askForEcdhInfo,
    		                         OSCoreCtxDB db, List<Set<Integer>> usedRecipientIds) 
            throws AceException, OSException {
        if (asResp == null) {
            throw new AceException("asResp cannot be null when POSTing to authz-info");
        }
        
        CBORObject asPayload;
        
        try {
            asPayload = CBORObject.DecodeFromBytes(asResp.getPayload());
        } catch (CBORException e) {
            throw new AceException("Error parsing CBOR payload: "+ e.getMessage());
        }
               
        if (!asPayload.getType().equals(CBORType.Map)) {
            throw new AceException("AS response was not a CBOR map");
        }
        
        CBORObject token = asPayload.get(
                CBORObject.FromObject(Constants.ACCESS_TOKEN));
        if (token == null) {
            throw new AceException("AS response did not contain a token");
        }
        if (token.getType() != CBORType.ByteString) {
            throw new AceException("The token must be a CBOR byte string");
        }
        
        CBORObject cnf = asPayload.get(
                CBORObject.FromObject(Constants.CNF));
        if (cnf == null) {
            throw new AceException("AS response did not contain a cnf");
        }
      
        if (!cnf.ContainsKey(Constants.OSCORE_Input_Material) || cnf.ContainsKey(Constants.COSE_KID_CBOR)) {
        	throw new AceException("Invalid format of cnf");
        }
        
        CBORObject payload = CBORObject.NewMap();
        payload.Add(Constants.ACCESS_TOKEN, token);
        
        if (askForSignInfo)
        	payload.Add(Constants.SIGN_INFO, CBORObject.Null);
        
        if (askForEcdhInfo)
        	payload.Add(Constants.ECDH_INFO, CBORObject.Null);
        
        byte[] n1 = new byte[8];
        new SecureRandom().nextBytes(n1);
        payload.Add(Constants.NONCE1, n1);
        
        byte[] recipientId = null;
		byte[] contextId = new byte[0];
        int recipientIdAsInt = -1;        
        boolean found = false;
        
        // Determine an available Recipient ID to offer to the Resource Server as ID1
        synchronized(usedRecipientIds) {
        	synchronized(db) {
        	
	        	int maxIdValue;

    			if (cnf.get(Constants.OSCORE_Input_Material).ContainsKey(Constants.OS_CONTEXTID)) {
    				contextId = cnf.get(Constants.OSCORE_Input_Material).get(Constants.OS_CONTEXTID).GetByteString();
    			}
	        	
		        // Start with 1 byte as size of Recipient ID; try with up to 4 bytes in size        
		        for (int idSize = 1; idSize <= 4; idSize++) {
		        	
		        	if (idSize == 4)
		        		maxIdValue = (1 << 31) - 1;
		        	else
		        		maxIdValue = (1 << (idSize * 8)) - 1;
		        	
			        for (int j = 0; j <= maxIdValue; j++) {
			        	
	        			recipientId = Util.intToBytes(j, idSize);
	        			
	        			// This Recipient ID is marked as not available to use
	        			if (usedRecipientIds.get(idSize - 1).contains(j))
	        				continue;
	        			
	        			try {
				        	// This Recipient ID seems to be available to use 
			        		if (!usedRecipientIds.get(idSize - 1).contains(j)) {
			        			
			        			// Double check in the database of OSCORE Security Contexts
			        			if (db.getContext(recipientId, contextId) != null) {
			        				
			        				// A Security Context with this Recipient ID exists and was not tracked!
			        				// Update the local list of used Recipient IDs, then move on to the next candidate
			        				usedRecipientIds.get(idSize - 1).add(j);
			        				continue;
			        				
			        			}
			        			else {
			        				
			        				// This Recipient ID is actually available at the moment. Add it to the local list
			        				usedRecipientIds.get(idSize - 1).add(j);
			        				recipientIdAsInt = j;
			        				found = true;
			        				break;
			        			}
			        			
			        		}
	        			}
		        		catch(CoapOSException e) {
	        				// Multiple Security Contexts with this Recipient ID exist and it was not tracked!
	        				// Update the local list of used Recipient IDs, then move on to the next candidate
	        				usedRecipientIds.get(idSize - 1).add(j);
	        				continue;
		        		}
		        			
			        }
			        
			        if (found)
			        	break;
			        	
		        }
        	}
        }

        if (!found) {
            throw new AceException("No Recipient ID available to use");
        }
        payload.Add(Constants.ACE_CLIENT_RECIPIENTID, recipientId);
        
        
        
        Response resp = null;
        CoapClient client = new CoapClient(rsAddr);
                
        try {
            LOGGER.finest("Sending request payload: " + payload);
            resp = client.post(
                    payload.EncodeToBytes(), 
                    Constants.APPLICATION_ACE_CBOR).advanced();
        } catch (ConnectorException | IOException ex) {
        	if (recipientIdAsInt != -1) {
        		usedRecipientIds.get(recipientId.length - 1).remove(recipientIdAsInt);
        	}
            LOGGER.severe("Connector error: " + ex.getMessage());
            throw new AceException(ex.getMessage());
        }

        if (resp == null) {
            throw new AceException("RS did not respond");
        }
        CBORObject rsPayload;
        try {
            rsPayload = CBORObject.DecodeFromBytes(resp.getPayload());
        } catch (CBORException e) {
            throw new AceException("Error parsing CBOR payload: " 
                    + e.getMessage());
        }
        
        if (!rsPayload.getType().equals(CBORType.Map)) {
            throw new AceException("RS didn't respond with a CBOR map");
        }
        
        if (askForSignInfo) {
        	
        	if (rsPayload.ContainsKey(CBORObject.FromObject(Constants.SIGN_INFO)) &&
        		rsPayload.get(CBORObject.FromObject(Constants.SIGN_INFO)).getType() != CBORType.Array) {
        			usedRecipientIds.get(recipientId.length - 1).remove(recipientIdAsInt);
                	throw new AceException("Malformed sign_info in the RS response");
        	}

        }
        
        if (askForEcdhInfo) {
        	
        	if (rsPayload.ContainsKey(CBORObject.FromObject(Constants.ECDH_INFO)) &&
        		rsPayload.get(CBORObject.FromObject(Constants.ECDH_INFO)).getType() != CBORType.Array) {
        			usedRecipientIds.get(recipientId.length - 1).remove(recipientIdAsInt);
                	throw new AceException("Malformed ecdh_info in the RS response");
        	}

        }
        
        CBORObject n2C = rsPayload.get(
                CBORObject.FromObject(Constants.NONCE2));
        if (n2C == null || !n2C.getType().equals(CBORType.ByteString)) {
            throw new AceException("Missing or malformed 'nonce2' in RS response");
        }
                
        byte[] n2 = n2C.GetByteString();
        
        CBORObject senderIdCBOR = rsPayload.get(
                CBORObject.FromObject(Constants.ACE_SERVER_RECIPIENTID));
        if (senderIdCBOR == null || !senderIdCBOR.getType().equals(CBORType.ByteString)) {
            throw new AceException("Missing or malformed 'id2' in RS response");
        }
        
        byte[] senderId = senderIdCBOR.GetByteString();
        
		// The Recipient ID must be different than what offered by the Resource Server in the 'id2' parameter
		if(Arrays.equals(senderId, recipientId)) {
            throw new AceException("The Resource Server returned the ID offered by the Client");
		}
        
    	cnf.get(Constants.OSCORE_Input_Material).Add(Constants.OS_CLIENTID, CBORObject.FromObject(senderId));
    	cnf.get(Constants.OSCORE_Input_Material).Add(Constants.OS_SERVERID, CBORObject.FromObject(recipientId));
        
        OscoreSecurityContext osc = new OscoreSecurityContext(cnf);
        
        OSCoreCtx ctx = osc.getContext(true, n1, n2);
        
        synchronized(db) {
        	
        	boolean install = true;
        	
			try {
        			
				// Double check in the database that the OSCORE Security Context
				// with the selected Recipient ID is actually still not present
    			if (db.getContext(recipientId, contextId) != null) {
    				// A Security Context with this Recipient ID exists!
    				install = false;
    			}        			
			}
    		catch(CoapOSException e) {
				// Multiple Security Contexts with this Recipient ID exist!
				install = false;
    		}
        	
			if (install)
				db.addContext(rsAddr, ctx);
			else
				throw new AceException("An OSCORE Security Context with the same Recipient ID"
						               + " has been installed while running the OSCORE profile");
        }
        
        return resp;
        
    }
    
    
    /**
     * Sends a POST request to the /authz-info endpoint of the RS to submit an
     * access token for updating access rights.
     * 
     * @param rsAddr   the full address of the /authz-info endpoint
     *  (including scheme and hostname, and port if not default)
     * @param asResp   the response from the AS containing the token
     *      and the access information
     * @param askForSignInfo  true when requesting information on the signature algorithm in the OSCORE group, false otherwise
     * @param db   the database of OSCORE Security Contexts
     * 
     * @return  the response 
     *
     * @throws AceException 
     * @throws OSException 
     */
    public static CoapResponse postTokenUpdate(String rsAddr, Response asResp, boolean askForSignInfo,
    										   boolean askForEcdhInfo, OSCoreCtxDB db) throws AceException, OSException {
        if (asResp == null) {
            throw new AceException("asResp cannot be null when POSTing to authz-info");
        }
        
        CBORObject asPayload;
        
        try {
            asPayload = CBORObject.DecodeFromBytes(asResp.getPayload());
        } catch (CBORException e) {
            throw new AceException("Error parsing CBOR payload: " + e.getMessage());
        }
               
        if (!asPayload.getType().equals(CBORType.Map)) {
            throw new AceException("AS response was not a CBOR map");
        }
        
        CBORObject token = asPayload.get(CBORObject.FromObject(Constants.ACCESS_TOKEN));
        if (token == null) {
            throw new AceException("AS response did not contain a token");
        }
        if (token.getType() != CBORType.ByteString) {
            throw new AceException("The token must be a CBOR byte string");
        }
        
        if (asPayload.ContainsKey(Constants.CNF)) {
            throw new AceException("AS response must not contain a cnf");
        }
        
        CBORObject payload = CBORObject.NewMap();
        payload.Add(Constants.ACCESS_TOKEN, token);
        
        if (askForSignInfo)
        	payload.Add(Constants.SIGN_INFO, CBORObject.Null);
        
        if (askForEcdhInfo)
        	payload.Add(Constants.ECDH_INFO, CBORObject.Null);
        
    	CoapResponse resp = null;

    	// The Token has to be posted through an OSCORE-protected request
        LOGGER.finest("Sending request payload: " + payload);
        CoapClient client = OSCOREProfileRequests.getClient(new InetSocketAddress(
        		rsAddr, CoAP.DEFAULT_COAP_PORT), db);
    	
        Request req = new Request(CoAP.Code.POST);
        req.getOptions().setContentFormat(Constants.APPLICATION_ACE_CBOR);
        req.getOptions().setOscore(new byte[0]);
        req.setPayload(payload.EncodeToBytes());
        try {
        	resp = client.advanced(req);
		} catch (ConnectorException | IOException ex) {
            LOGGER.severe("Connector error: " + ex.getMessage());
            throw new AceException(ex.getMessage());
		}
        
        if (resp == null) {
            throw new AceException("RS did not respond");
        }
        
        CBORObject rsPayload;
        try {
            rsPayload = CBORObject.DecodeFromBytes(resp.getPayload());
        } catch (CBORException e) {
            throw new AceException("Error parsing CBOR payload: " + e.getMessage());
        }
        
        if (!rsPayload.getType().equals(CBORType.Map)) {
            throw new AceException("RS didn't respond with a CBOR map");
        }
        
        if (askForSignInfo) {
        	        	
        	if (rsPayload.ContainsKey(CBORObject.FromObject(Constants.SIGN_INFO)) &&
        		rsPayload.get(CBORObject.FromObject(Constants.SIGN_INFO)).getType() != CBORType.Array) {
                	throw new AceException("Malformed sign_info in the RS response");
        	}

        }
        
        if (askForEcdhInfo) {
        	
        	if (rsPayload.ContainsKey(CBORObject.FromObject(Constants.ECDH_INFO)) &&
        		rsPayload.get(CBORObject.FromObject(Constants.ECDH_INFO)).getType() != CBORType.Array) {
                	throw new AceException("Malformed ecdh_info in the RS response");
        	}

        }
        
        return resp;
        
    }
    
    
    /**
     * Generates a Coap client for sending requests to an RS using OSCORE.
     * Note that the OSCORE context for the RS should already be configured 
     * in the OSCoreCtxDb at this point.
     * 
     * @param serverAddress  the address of the server and resource this client
     *  should talk to.
     * 
     * @return  a CoAP client configured to pass the access token through the
     *  psk-identity in the handshake 
     * @throws AceException 
     * @throws OSException 
     * @throws URISyntaxException 
     */
    public static CoapClient getClient(InetSocketAddress serverAddress, OSCoreCtxDB db) 
            throws AceException, OSException {
        if (serverAddress == null || serverAddress.getHostString() == null) {
            throw new IllegalArgumentException(
                    "Client requires a non-null server address");
        }

        if (db.getContext(serverAddress.getHostName()) == null) {
            throw new AceException("OSCORE context not set for address: " 
                    + serverAddress);
        }
        CoapEndpoint.Builder builder = new CoapEndpoint.Builder();
        builder.setCoapStackFactory(new OSCoreCoapStackFactory());
        builder.setCustomCoapStackArgument(db);
        Endpoint clientEndpoint = builder.build();
        CoapClient client = new CoapClient(serverAddress.getHostString());
        client.setEndpoint(clientEndpoint);
        return client;    
    }
}