TokenRepository.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.rs;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Scanner;
import java.util.Set;
import java.util.logging.Logger;
import org.eclipse.californium.elements.auth.RawPublicKeyIdentity;
import org.eclipse.californium.oscore.CoapOSException;
import org.eclipse.californium.oscore.OSCoreCtx;
import org.eclipse.californium.oscore.OSCoreCtxDB;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import com.upokecenter.cbor.CBORObject;
import com.upokecenter.cbor.CBORType;
import org.eclipse.californium.cose.CoseException;
import org.eclipse.californium.cose.Encrypt0Message;
import org.eclipse.californium.cose.KeyKeys;
import org.eclipse.californium.cose.OneKey;
import se.sics.ace.AceException;
import se.sics.ace.Constants;
import se.sics.ace.Hkdf;
import se.sics.ace.TimeProvider;
import se.sics.ace.coap.rs.oscoreProfile.OscoreCtxDbSingleton;
import se.sics.ace.coap.rs.oscoreProfile.OscoreSecurityContext;
import se.sics.ace.cwt.CwtCryptoCtx;
/**
* This class is used to store valid access tokens and
* provides methods to check them against an incoming request. It is the
* responsibility of the request handler to call this class.
*
* Note that this class assumes that every token has a 'scope',
* 'aud', and 'cnf'. Tokens
* that don't have these will lead to request failure.
*
* If the token has no cti, this class will use the hashCode() of the claims
* Map to generate a local cti.
*
* This class is implemented as a singleton to ensure that all users see
* the same repository (and yes I know that parameterized singletons are bad
* style, go ahead and suggest a better solution).
*
* @author Ludwig Seitz and Marco Tiloca
*
*/
public class TokenRepository implements AutoCloseable {
/**
* Return codes of the canAccess() method
*/
public static final int OK = 1;
/**
* Return codes of the canAccess() method. 4.01 Unauthorized
*/
public static final int UNAUTHZ = 0;
/**
* Return codes of the canAccess() method. 4.03 Forbidden
*/
public static final int FORBID = -1;
/**
* Return codes of the canAccess() method. 4.05 Method Not Allowed
*/
public static final int METHODNA = -2;
/**
* Converter for generating byte arrays from int
*/
private static ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES);
/**
* The logger
*/
private static final Logger LOGGER
= Logger.getLogger(TokenRepository.class.getName());
/**
* Is this closed?
*/
private boolean closed = true;
/**
* Maps the base64 encoded cti to the claims of the corresponding token
*/
private Map<String, Map<Short, CBORObject>> cti2claims;
/**
* Map key identifiers collected from the access tokens to keys
*/
protected Map<String, OneKey> kid2key;
/**
* Map the base64 encoded cti of a token to the corresponding pop-key kid
*/
protected Map<String, String>cti2kid;
/**
* Map a subject identity to the kid they use
*/
private Map<String, String>sid2kid;
/**
* Map a subject identity to the base64 encoded cti of a token
*/
private Map<String, String>sid2cti;
/**
* Map an OSCORE input material identifier to the base64 encoded cti of a token
*/
private Map<String, String>id2cti;
/**
* Map a subject identity to an OSCORE input material identifier
*/
private Map<String, String>sid2id;
/**
* Map a subject identity to the rsnonce possibly provided upon Token posting
* This is relevant when joining an OSCORE Group, with the RS acting as Group Manager
*/
private Map<String, String> sid2rsnonce;
/**
* The scope validator
*/
private ScopeValidator scopeValidator;
/**
* The filename + path for the JSON file in which the tokens are stored
*/
private String tokenFile;
/**
* The time provider providing local time for this RS
*/
private TimeProvider time;
/**
* The key derivation key to use with the AS
*/
private byte[] keyDerivationKey;
/**
* The size in bytes for symmetric keys derived with the key derivation key
*/
private int derivedKeySize;
/**
* The singleton instance
*/
private static TokenRepository singleton = null;
/**
* The identifier of the Resource Server.
*
* This is required to process Access Tokens that include the 'exi' claim,
* where the format of the 'cti' claim also encodes the identifier of the
* Resource Server together with a Sequence Number value used for such Access Tokens.
*/
private String rsId;
/**
* Related to Access Tokens including the 'exi' claim, this has as value the highest
* Sequence Number received in any of such Tokens, as encoded in the 'cti' claim
*/
private int topExiSequenceNumber;
/**
* The singleton getter.
* Note: The caller is expected to check if the singleton was initialized
* with TokenRepository.create().
*
* @return the singleton repository
*/
public static TokenRepository getInstance() {
return singleton;
}
/**
* Creates the one and only instance of the token repo and loads the
* existing tokens from a JSON file is there is one.
*
* The JSON file stores the tokens as a JSON array of JSON maps,
* where each map represents the claims of a token, String mapped to
* the Base64 encoded byte representation of the CBORObject.
*
* @param scopeValidator the validator for scopes
* @param tokenFile the file where to save tokens
* @param ctx the crypto context
* @param keyDerivationKey the key derivation key, it can be null
* @param derivedKeySize the size in bytes of symmetric keys derived with the key derivation key
* @param time the time provider for this RS
* @param rsId the identifier of this RS
* @throws AceException
* @throws IOException
*/
public static void create(ScopeValidator scopeValidator,
String tokenFile, CwtCryptoCtx ctx, byte[] keyDerivationKey, int derivedKeySize, TimeProvider time, String rsId)
throws AceException, IOException {
if (singleton != null) {
throw new AceException("Token repository already exists");
}
singleton = new TokenRepository(scopeValidator, tokenFile, ctx, keyDerivationKey, derivedKeySize, time, rsId);
}
/**
* Creates a new token repository and loads the existing tokens
* from a JSON file is there is one.
*
* The JSON file stores the tokens as a JSON array of JSON maps,
* where each map represents the claims of a token, String mapped to
* the Base64 encoded byte representation of the CBORObject.
*
* @param scopeValidator the application specific scope validator
* @param tokenFile the file storing the existing tokens, if the file does not exist it is created
* @param ctx the crypto context for reading encrypted tokens
* @param keyDerivationKey the key derivation key to use to derive PoP keys, it can be null
* @param time the time provider for this RS
* @param rsId the identifier of this RS
*
* @throws IOException
* @throws AceException
*/
protected TokenRepository(ScopeValidator scopeValidator,
String tokenFile, CwtCryptoCtx ctx, byte[] keyDerivationKey, int derivedKeySize, TimeProvider time, String rsId)
throws IOException, AceException {
this.closed = false;
this.cti2claims = new HashMap<>();
this.kid2key = new HashMap<>();
this.cti2kid = new HashMap<>();
this.sid2kid = new HashMap<>();
this.sid2cti = new HashMap<>();
this.id2cti = new HashMap<>();
this.sid2id = new HashMap<>();
this.sid2rsnonce = new HashMap<>();
this.scopeValidator = scopeValidator;
this.time = time;
this.keyDerivationKey = keyDerivationKey;
this.derivedKeySize = derivedKeySize;
this.topExiSequenceNumber = -1;
this.rsId = rsId;
if (tokenFile == null) {
throw new IllegalArgumentException("Must provide a token file path");
}
this.tokenFile = tokenFile;
File f = new File(this.tokenFile);
if (!f.exists()) {
return; //File will be created if tokens are added
}
FileInputStream fis = new FileInputStream(f);
Scanner scanner = new Scanner(fis, "UTF-8");
Scanner s = scanner.useDelimiter("\\A");
String configStr = s.hasNext() ? s.next() : "";
s.close();
scanner.close();
fis.close();
JSONArray config = null;
if (!configStr.isEmpty()) {
config = new JSONArray(configStr);
Iterator<Object> iter = config.iterator();
while (iter.hasNext()) {
Object foo = iter.next();
if (!(foo instanceof JSONObject)) {
throw new AceException("Token file is malformed");
}
JSONObject token = (JSONObject)foo;
Iterator<String> iterToken = token.keys();
Map<Short, CBORObject> params = new HashMap<>();
while (iterToken.hasNext()) {
String key = iterToken.next();
params.put(Short.parseShort(key),
CBORObject.DecodeFromBytes(
Base64.getDecoder().decode(
token.getString((key)))));
}
this.addToken(null, params, ctx, null, -1);
}
}
}
/**
* Add a new Access Token to the repo. Note that this method DOES NOT
* check the validity of the token.
*
* @param token the token
* @param claims the claims of the token
* @param ctx the crypto context of this RS
* @param sid the subject identity of the user of this token, or null if not needed
*
* @param exiSeqNum the Sequence Number for an Access Token including the 'exi claim.
* - If its value is -1 and the Access Token includes an 'exi' claim, then the
* Access Token has been retrieved from a file, and the actual Sequence Number
* has to be retrieved again from the 'cti' claim.
* - If its value is a positive integer and the Access Token includes an 'exi' claim,
* this is the actual Sequence Number already retrieved from the 'cti' claim by
* the Access Token processing at the /authz-info endpoint
* - Any further negative integer value is not relevant
*
* @return the cti or the local id given to this token
*
* @throws AceException
*/
public synchronized CBORObject addToken(CBORObject token, Map<Short, CBORObject> claims,
CwtCryptoCtx ctx, String sid, int exiSeqNum) throws AceException {
CBORObject so = claims.get(Constants.SCOPE);
if (so == null) {
throw new AceException("Token has no scope");
}
CBORObject cticb = claims.get(Constants.CTI);
String cti = null;
if (cticb == null) {
cticb = CBORObject.FromObject(
buffer.putInt(0, claims.hashCode()).array());
cti = Base64.getEncoder().encodeToString(cticb.GetByteString());
claims.put(Constants.CTI, cticb);
} else if (!cticb.getType().equals(CBORType.ByteString)) {
LOGGER.info("Token's cti in not a ByteString");
throw new AceException("Cti has invalid format");
} else {
cti = Base64.getEncoder().encodeToString(cticb.GetByteString());
}
//Store the pop-key
boolean storeKey = true;
CBORObject cnf = claims.get(Constants.CNF);
if (cnf == null) {
LOGGER.severe("Token has not cnf");
throw new AceException("Token has no cnf");
}
if (!cnf.getType().equals(CBORType.Map)) {
LOGGER.severe("Malformed cnf in token");
throw new AceException("cnf claim malformed in token");
}
//Check for duplicate cti
boolean repostedOscoreToken = false;
byte[] oldOscoreRecipientId = null;
byte[] oldOscoreContextId = null;
if (this.cti2claims.containsKey(cti)) {
if (cnf.getKeys().contains(Constants.OSCORE_Input_Material) && sid == null) {
// This is a re-POST of the same Token through an insecure request under the OSCORE profile.
//
// This is admitted and results in a new exchange of nonces N1 and N2, together with the
// establishment of a new OSCORE Security Context, which /authz-info already takes care of.
// The already stored token must also have been related to OSCORE
CBORObject storedCnf = this.cti2claims.get(cti).get(Constants.CNF);
if (storedCnf.getKeys().contains(Constants.OSCORE_Input_Material) == false) {
throw new AceException("Duplicate cti");
}
// This same Token remains. Later on, it has to be associated with the new
// client identity and the old OSCORE Security Context has to be deleted.
repostedOscoreToken = true;
oldOscoreRecipientId = storedCnf.get(Constants.OSCORE_Input_Material).
get(Constants.OS_CLIENTID).GetByteString();
oldOscoreContextId = storedCnf.get(Constants.OSCORE_Input_Material).
get(Constants.OS_CONTEXTID).GetByteString();
}
else {
throw new AceException("Duplicate cti");
}
}
if (cnf.getKeys().contains(Constants.COSE_KEY_CBOR)) {
CBORObject ckey = cnf.get(Constants.COSE_KEY_CBOR);
try {
// The PoP key is symmetric but only its 'kid' is specified (e.g., as in the DTLS profile).
if (ckey.getKeys().contains(KeyKeys.KeyType.AsCBOR()) &&
ckey.get(KeyKeys.KeyType.AsCBOR()).equals(KeyKeys.KeyType_Octet) &&
ckey.getKeys().contains(KeyKeys.Octet_K.AsCBOR()) == false) {
if (sid == null) {
// The Token has been posted to /authz-info through an unprotected message.
// The actual PoP key has to be derived using the key derivation key shared with the AS
if (ckey.getKeys().contains(KeyKeys.KeyId.AsCBOR()) == false) {
LOGGER.severe("Error while parsing cnf element: expected 'kid' in 'COSE_Key was not found");
throw new AceException("Invalid cnf element: expected 'kid' in 'COSE_Key was not found");
}
// Check also that a PoP key with the same received 'kid' is not already stored.
//
// That would be fine for a Token posted to update access rights,
// which must however happen through a secure POST to /authz-info
CBORObject kidC = ckey.get(KeyKeys.KeyId.AsCBOR());
if (kidC == null) {
LOGGER.severe("kid not found in COSE_Key");
throw new AceException("COSE_Key is missing kid");
} else if (kidC.getType().equals(CBORType.ByteString)) {
String kid = Base64.getEncoder().encodeToString(kidC.GetByteString());
if (kid2key.containsKey(kid) == true) {
LOGGER.severe("A symmetric PoP key with the specified 'kid' is already stored");
throw new AceException("A symmetric PoP key with the specified 'kid' is already stored");
}
} else {
LOGGER.severe("kid is not a byte string");
throw new AceException("COSE_Key contains invalid kid");
}
// The salt as empty byte string has to be an array of bytes with all its
// elements set to 0x00 and with the same size of the hash output in bytes
byte[] salt = new byte[Hkdf.getHashLen()];
Arrays.fill(salt, (byte) 0);
// The 'info' structure
byte[] derivedKey = null;
CBORObject info = CBORObject.NewArray();
info.Add("ACE-CoAP-DTLS-key-derivation");
info.Add(derivedKeySize);
info.Add(token.EncodeToBytes()); // The content of the "access_token" field, as transferred
// from the authorization server to the resource server.
try {
derivedKey = Hkdf.extractExpand(salt, keyDerivationKey, info.EncodeToBytes(), derivedKeySize);
} catch (InvalidKeyException e) {
LOGGER.severe("Error while deriving a symmetric PoP key: "
+ e.getMessage());
throw new AceException("Error while deriving a symmetric PoP key: "
+ e.getMessage());
} catch (NoSuchAlgorithmException e) {
LOGGER.severe("Error while deriving a symmetric PoP key: "
+ e.getMessage());
throw new AceException("Error while deriving a symmetric PoP key: "
+ e.getMessage());
}
ckey.Add(KeyKeys.Octet_K.AsCBOR(), CBORObject.FromObject(derivedKey));
}
else {
// Since there is a non-null identity, either:
// i) the Token has been posted through a protected message to /authz-info , to update access rights; or
// ii) the Token has been specified in the DTLS handshake message, as "psk_identity"
// Case (i), i.e. the current Token for this security association must be superseded
if (sid2kid.containsKey(sid) && sid2cti.containsKey(sid)) {
if (ckey.getKeys().contains(KeyKeys.KeyId.AsCBOR()) == false) {
LOGGER.severe("Error while parsing cnf element: expected 'kid' in 'COSE_Key was not found");
throw new AceException("Invalid cnf element: expected 'kid' in 'COSE_Key was not found");
}
// Check if there is a stored Token associated to this subject ID
String storedCti = sid2cti.get(sid);
// A Token was found - This implies that the corresponding security association
// is the same one used to protect the received Token POST request
if (storedCti != null) {
// Now check that the stored Token is actually bound to a key with that 'kid'
String retrievedKid = cti2kid.get(storedCti);
byte[] receivedKidBytes = ckey.get(KeyKeys.KeyId.AsCBOR()).GetByteString();
String receivedKid = Base64.getEncoder().encodeToString(receivedKidBytes);
if (!retrievedKid.equals(sid2kid.get(sid)) || !retrievedKid.equals(receivedKid)) {
LOGGER.severe("Impossible to retrieve a Token to supersede");
throw new AceException("Impossible to retrieve a Token to supersede");
}
// Everything has matched - This Token is intended to update access rights, while
// preserving the same security association used to protect this Token POST and
// associated to the Token to supersede
Map<Short, CBORObject> storedClaims = cti2claims.get(storedCti);
CBORObject storedCnf = storedClaims.get(Constants.CNF);
// The following should never happen, being this an already stored Token
if (storedCnf == null) {
LOGGER.severe("The retrieved stored token has not cnf");
throw new AceException("The retrieved stored token has no cnf");
}
if (!storedCnf.getType().equals(CBORType.Map)) {
LOGGER.severe("Malformed cnf in the retrieved stored token");
throw new AceException("cnf claim malformed in the retrieved stored token");
}
// Copy the "full" 'cnf' claim of the Token to replace into the new Token to store.
// This will overwrite the orginal 'cnf' considered above in the new Token to store.
claims.put(Constants.CNF, storedCnf);
// Store the association between the CTI of the new Token and the same current kid
this.cti2kid.put(cti, receivedKid);
// Store the association between the same current subjectId and the CTI of the new Token
this.sid2cti.put(sid, cti);
// The same PoP key remains in use
storeKey = false;
// Delete the Token to be replaced
removeToken(storedCti);
}
else {
LOGGER.severe("Impossible to retrieve the stored Token to supersede");
throw new AceException("Impossible to retrieve the stored Token to supersede");
}
}
// Else it's Case (ii), which will be handled later in processKey()
}
}
if (storeKey) {
OneKey key = new OneKey(ckey);
processKey(key, sid, cti);
}
}
catch (CoseException e) {
LOGGER.severe("Error while parsing cnf element: " + e.getMessage());
throw new AceException("Invalid cnf element: " + e.getMessage());
}
}
else if (cnf.getKeys().contains(Constants.COSE_ENCRYPTED_CBOR)) {
Encrypt0Message msg = new Encrypt0Message();
CBORObject encC = cnf.get(Constants.COSE_ENCRYPTED_CBOR);
try {
msg.DecodeFromCBORObject(encC);
msg.decrypt(ctx.getKey());
CBORObject keyData = CBORObject.DecodeFromBytes(msg.GetContent());
OneKey key = new OneKey(keyData);
processKey(key, sid, cti);
} catch (CoseException e) {
LOGGER.severe("Error while decrypting a cnf claim: "
+ e.getMessage());
throw new AceException("Error while decrypting a cnf claim");
}
}
else if (cnf.getKeys().contains(Constants.COSE_KID_CBOR)) {
String kid = null;
CBORObject kidC = cnf.get(Constants.COSE_KID_CBOR);
if (kidC.getType().equals(CBORType.ByteString)) {
kid = Base64.getEncoder().encodeToString(kidC.GetByteString());
} else {
LOGGER.severe("kid is not a byte string");
throw new AceException("cnf contains invalid kid");
}
// The Token POST is protected
if (sid != null) {
// The Token POST can be protected with OSCORE, for
// updating access rights as per the OSCORE profile
// Check if there is a stored Token associated to this subject ID
String storedCti = sid2cti.get(sid);
// A Token was found - This implies that the corresponding security association
// is the same one used to protect the received Token POST request
if (storedCti != null) {
// Now check that the stored Token is actually
// associated to an OSCORE Security Context
Map<Short, CBORObject> storedClaims = cti2claims.get(storedCti);
CBORObject storedCnf = storedClaims.get(Constants.CNF);
// The following should never happen, being this an already stored Token
if (storedCnf == null) {
LOGGER.severe("The retrieved stored token has not cnf");
throw new AceException("The retrieved stored token has no cnf");
}
if (!storedCnf.getType().equals(CBORType.Map)) {
LOGGER.severe("Malformed cnf in the retrieved stored token");
throw new AceException("cnf claim malformed in the retrieved stored token");
}
if (storedCnf.getKeys().contains(Constants.OSCORE_Input_Material)) {
byte[] storedIdBytes = storedCnf.get(Constants.OSCORE_Input_Material).
get(Constants.OS_ID).GetByteString();
String storedId = Base64.getEncoder().encodeToString(storedIdBytes);
String recoveredCti = id2cti.get(storedId);
if (!storedCti.equals(recoveredCti) || !storedId.equals(kid) ) {
LOGGER.severe("Impossible to retrieve an OSCORE-related Token to supersede");
throw new AceException("Impossible to retrieve an OSCORE-related Token to supersede");
}
// Everything has matched - This Token is intended to update access rights, while
// preserving the same OSCORE Security Context used to protect this Token POST
// and associated to the Token to supersede
// Copy the "full" 'cnf' claim of the Token to replace into the new Token to store.
// This will overwrite the original 'cnf' considered above in the new Token to store.
claims.put(Constants.CNF, storedCnf);
// Store the association between the same current subjectId and the CTI of the new Token
this.sid2cti.put(sid, cti);
// Store the association between the CTI of the new Token and kid, with kid equal to the subjectId
this.cti2kid.put(cti, sid);
// Store the association between the immutable identifier of the OSCORE input material
// and the base64 encoded cti of this Access Token; this will be updated in case a new
// Access Token with updated access rights (and a new cti) is posted as still associated
// to this OSCORE input material identifier and hence to the same kid
this.id2cti.put(kid, cti);
// Delete the old Token that has been replaced
removeToken(storedCti);
}
else {
// The only admitted situation for 'cnf' of 'kid' type for a protected Token POST
// is the one described in the OSCORE profile for the update of access rights.
// Any other case should be treated as an error at the moment.
LOGGER.severe("A Token to supersede through 'cnf' of type 'kid' must be"
+ "related to an OSCORE Security Context");
throw new AceException("A Token to supersede through 'cnf' of type 'kid' must be"
+ "related to an OSCORE Security Context");
}
}
else {
LOGGER.severe("Impossible to retrieve the stored Token to supersede");
throw new AceException("Impossible to retrieve the stored Token to supersede");
}
}
// The Token POST is not protected
else {
if (!this.kid2key.containsKey(kid)) {
LOGGER.info("Token refers to unknown kid");
throw new AceException("Token refers to unknown kid");
}
//Store the association between token and known key
this.cti2kid.put(cti, kid);
// Since the Token POST is not protected, there is no Subject ID available
// at all for the moment, to store the associations sid2kid and sid2cti
// NOTE: Current profiles do not support this case
}
}
else if (cnf.getKeys().contains(Constants.OSCORE_Input_Material)) {
// Coming from the /authz-info endpoint, it is ensured that
// this Token has been posted through an unprotected request
OscoreSecurityContext osc = new OscoreSecurityContext(cnf);
String kid = Base64.getEncoder().encodeToString(osc.getClientId());
// The subject ID stored in the Token Repository has format: i) IdContext:SenderID;
// or ii) SenderID, if the IdContext is not in the OSCORE Security Context Object
String subjectId = "";
String kidContext = null;
byte[] kidContextBytes = osc.getContextId();
if (kidContextBytes != null && kidContextBytes.length != 0) {
kidContext = Base64.getEncoder().encodeToString(kidContextBytes);
subjectId = kidContext + ":";
}
subjectId += kid;
// Store the association between subjectId and kid, with kid equal to the subjectId
this.sid2kid.put(subjectId, subjectId);
// Store the association between subjectId and the Token CTI
this.sid2cti.put(subjectId, cti);
// Store the association between CTI and kid, with kid equal to the subjectId
this.cti2kid.put(cti, subjectId);
if (repostedOscoreToken == true) {
// The same Token has been reposted through an unprotected request
// Delete the old OSCORE Security Context
OSCoreCtxDB db = OscoreCtxDbSingleton.getInstance();
OSCoreCtx oscCtx = null;
if (oldOscoreContextId == null) {
oscCtx = db.getContext(oldOscoreRecipientId);
}
else {
try {
oscCtx = db.getContext(oldOscoreRecipientId, oldOscoreContextId);
} catch (CoapOSException e) {
e.printStackTrace();
LOGGER.severe("Unable to retrieve the OSCORE Security Context to delete");
throw new AceException("Unable to retrieve the OSCORE Security Context to delete");
}
}
if (oscCtx != null) {
db.removeContext(oscCtx);
}
else {
LOGGER.severe("Unable to retrieve the OSCORE Security Context to delete");
throw new AceException("Unable to retrieve the OSCORE Security Context to delete");
}
}
else {
// Store the association between the immutable identifier of the OSCORE input material
// and the base64 encoded cti of this Access Token; this will be updated in case a new
// Access Token with updated access rights (and a new cti) is posted as still associated
// to this OSCORE input material identifier and hence to the same kid
String id = Base64.getEncoder().encodeToString(osc.getId());
this.id2cti.put(id, cti);
// Store the association between the subjectId and
// the immutable identifier of the OSCORE input material
this.sid2id.put(subjectId, id);
}
}
else {
LOGGER.severe("Malformed cnf claim in token");
throw new AceException("Malformed cnf claim in token");
}
// If the Access Token includes the 'exi' claim, update the stored
// highest Sequence Number values used to track the Access Tokens
// with the 'exi' claim issues to this Resource Server
if (claims.containsKey(Constants.EXI)) {
if (exiSeqNum >= 0) {
// The Access Token has been just posted to authz-info
TokenRepository.getInstance().setTopExiSequenceNumber(exiSeqNum);
}
else if (exiSeqNum == -1) {
// The Access Token has been retrieved from a local file
exiSeqNum = getExiSeqNumFromCti(cticb.GetByteString());
if (exiSeqNum < 0) {
// This should never happen, since the Access Token retrieved from the local file
// should have been issued by the AS as including a 'cti' claim with the intended format
LOGGER.severe("Malformed cti claim in token including an exi claim and restored from a local file");
throw new AceException("Malformed cti claim in token including an exi claim and restored from a local file");
}
TokenRepository.getInstance().setTopExiSequenceNumber(exiSeqNum);
}
}
//Now store the claims. Need deep copy here
Map<Short, CBORObject> foo = new HashMap<>();
foo.putAll(claims);
this.cti2claims.put(cti, foo);
persist();
return cticb;
}
/**
* Add the mappings for the cnf-key.
*
* @param key the key
* @param sid the subject identifier
* @param cti the token's identifier
*
* @throws AceException
* @throws CoseException
*/
private void processKey(OneKey key, String sid, String cti)
throws AceException, CoseException {
String kid = null;
CBORObject kidC = null;
if (key.get(KeyKeys.KeyType).equals(KeyKeys.KeyType_Octet)) {
kidC = key.get(KeyKeys.KeyId);
if (kidC == null) {
LOGGER.severe("kid not found in COSE_Key");
throw new AceException("COSE_Key is missing kid");
} else if (kidC.getType().equals(CBORType.ByteString)) {
kid = Base64.getEncoder().encodeToString(kidC.GetByteString());
} else {
LOGGER.severe("kid is not a byte string");
throw new AceException("COSE_Key contains invalid kid");
}
}
else { //Key type is EC2
RawPublicKeyIdentity rpk = new RawPublicKeyIdentity(key.AsPublicKey());
kid = Base64.getEncoder().encodeToString(rpk.getName().getBytes());
}
if (sid != null) {
// Receiving a new PoP key through an already identifiable peer should
// happen only in the DTLS profile, and only when the whole Token conveying
// a symmetric PoP key is transported within the DTLS handshake message.
// Add the new subject ID only if it is actually new, i.e. this is
// not an attempt to update access rights of an already stored Token
if (!sid2kid.containsKey(sid) && !sid2cti.containsKey(sid)) {
this.sid2kid.put(sid, kid);
this.sid2cti.put(sid, cti);
}
else {
LOGGER.severe("A new PoP key must be provided through an unprotected Token POST");
throw new AceException("A new PoP key must be provided through an unprotected Token POST");
}
}
else if (key.get(KeyKeys.KeyType).equals(KeyKeys.KeyType_EC2) ||
key.get(KeyKeys.KeyType).equals(KeyKeys.KeyType_OKP)) {
//Scandium needs a special mapping for raw public keys
RawPublicKeyIdentity rpk = new RawPublicKeyIdentity(key.AsPublicKey());
this.sid2kid.put(rpk.getName(), kid);
this.sid2cti.put(rpk.getName(), cti);
}
else { //Take the kid as sid
this.sid2kid.put(kid, kid);
this.sid2cti.put(kid, cti);
}
this.cti2kid.put(cti, kid);
this.kid2key.put(kid, key);
}
/**
* Remove an existing token from the repository.
*
* @param cti the cti of the token to be removed Base64 encoded.
* @throws AceException
*/
public synchronized void removeToken(String cti) throws AceException {
if (cti == null) {
throw new AceException("Cti is null");
}
// Remove the claims
this.cti2claims.remove(cti);
// Remove the mapping to the pop key
this.cti2kid.remove(cti);
// Remove unused keys
Set<String> remove = new HashSet<>();
for (String kid : this.kid2key.keySet()) {
if (!this.cti2kid.containsValue(kid)) {
remove.add(kid);
}
}
for (String kid : remove) {
this.kid2key.remove(kid);
}
// Remove the mapping from the subject ID to cti
remove = new HashSet<>();
for (String sid : this.sid2cti.keySet()) {
if (this.sid2cti.get(sid).equals(cti)) {
remove.add(sid);
}
}
for (String sid : remove) {
this.sid2cti.remove(sid);
}
// Remove unused kids
remove = new HashSet<>();
for (String sid : this.sid2kid.keySet()) {
if (!this.sid2cti.containsKey(sid)) {
remove.add(sid);
}
}
for (String sid : remove) {
this.sid2kid.remove(sid);
}
// Remove unused rs nonces
// Relevant when joining an OSCORE Group, with the RS acting as Group Manager
remove = new HashSet<>();
for (String sid : this.sid2rsnonce.keySet()) {
if (!this.sid2cti.containsKey(sid)) {
remove.add(sid);
}
}
for (String sid : remove) {
this.sid2rsnonce.remove(sid);
}
// Remove the mapping from an OSCORE ID to cti,
// if the Token was established with the OSCORE profile
remove = new HashSet<>();
for (String id : this.id2cti.keySet()) {
if (this.id2cti.get(id).equals(cti)) {
remove.add(id);
}
}
for (String id : remove) {
this.id2cti.remove(id);
// Remove the mapping from the subject ID to the OSCORE Input Material ID
Set<String> sidsToRemove = new HashSet<>();
for (String sid: sid2id.keySet()) {
if (sid2id.get(sid).equals(id)) {
sidsToRemove.add(sid);
}
}
for (String sid: sidsToRemove) {
sid2id.remove(sid);
// Remove the OSCORE Security Context
int index = sid.indexOf(":");
byte[] idContext = null;
if (index >= 0) {
// Extract the OSCORE ID Context
String idContextString = sid.substring(0, index);
idContext = Base64.getDecoder().decode(idContextString);
}
String recipientIdString = sid.substring(index+1, sid.length());
byte[] recipientId = Base64.getDecoder().decode(recipientIdString);
OSCoreCtxDB db = OscoreCtxDbSingleton.getInstance();
try {
OSCoreCtx ctx = db.getContext(recipientId, idContext);
db.removeContext(ctx);
} catch (CoapOSException e) {
e.printStackTrace();
LOGGER.severe("Unable to retrieve the OSCORE Security Context to delete");
throw new AceException("Unable to retrieve the OSCORE Security Context to delete");
}
}
}
persist();
}
/**
* Poll the stored tokens and expunge those that have expired.
*
* Note that non-expired tokens might also be expunged, if including the 'exi' claim
*
* @throws AceException
*/
public synchronized void purgeTokens() throws AceException {
// Set of Access Tokens to remove, due to the possible following reasons:
// - The Access Token is expired
// - The Access Token is not expired, but: it includes the 'exi' claim; and
// its associated Sequence Number is smaller than the highest Sequence Number
// among the expired Access Tokens to remove that include the 'exi' claim
HashSet<String> tokenToRemove = new HashSet<>();
// Set of non-expired Access Tokens that include the 'exi' claim
HashSet<String> tokenWithExiNotExpired = new HashSet<>();
// Highest Sequence Number among the expired
// Access Tokens to remove that include the 'exi' claim
int highestExiSeqNum = -1;
// Phase 1: identify and delete the expired Access Tokens
for (Map.Entry<String, Map<Short, CBORObject>> foo : this.cti2claims.entrySet()) {
if (foo.getValue() != null) {
CBORObject exi = foo.getValue().get(Constants.EXI);
CBORObject exp = foo.getValue().get(Constants.EXP);
if (exp == null) {
continue; //This token never expires
}
if (!(exp.isNumber() && exp.AsNumber().IsInteger())) {
throw new AceException("Expiration time is in wrong format");
}
if (this.time.getCurrentTime() > exp.AsNumber().ToInt64Checked()) {
// This Access Token is expired and has to be removed
tokenToRemove.add(foo.getKey());
if (exi != null) {
// This expired Access Token has an 'exi' claim
CBORObject cticb = foo.getValue().get(Constants.CTI);
int exiSeqNum = getExiSeqNumFromCti(cticb.GetByteString());
if (exiSeqNum < 0) {
// This should never happen, since an accepted and stored Access Token
// should have been validated as including a 'cti' claim with the intended format
LOGGER.severe("Malformed cti claim in stored token including an exi claim");
throw new AceException("Malformed cti claim in stored token including an exi claim");
}
// Track the highest Sequence Number among the expired Access Tokens with the 'exi' claim
if (exiSeqNum > highestExiSeqNum) {
highestExiSeqNum = exiSeqNum;
}
}
}
else if (exi != null) {
// The Access Token is not expired, but it includes the 'exi' claim
// and thus will require further inspection for possible deletion
tokenWithExiNotExpired.add(foo.getKey());
}
}
}
// Delete the expired Access Tokens
for (String cti : tokenToRemove) {
removeToken(cti);
}
// Phase 2: identify and delete the non-expired Access Tokens that include the 'exi' claim and that
// have their Sequence Number smaller than the highest Sequence Number previously identified.
// This can be skipped altogether if any of the two following conditions holds:
// - There are no non-expired Access Tokens that include the 'exi' claim; OR
// - No expired Access Tokens including the 'exi' claim were found and deleted
if (!tokenWithExiNotExpired.isEmpty() || highestExiSeqNum != -1) {
tokenToRemove = new HashSet<>();
for (Map.Entry<String, Map<Short, CBORObject>> foo : this.cti2claims.entrySet()) {
if (foo.getValue() != null) {
if (tokenWithExiNotExpired.contains(foo.getKey())) {
int exiSeqNum = -1;
CBORObject cticb = foo.getValue().get(Constants.CTI);
exiSeqNum = getExiSeqNumFromCti(cticb.GetByteString());
if (exiSeqNum < 0) {
// This should never happen, since an accepted and stored Access Token
// should have been validated as including a 'cti' claim with the intended format
LOGGER.severe("Malformed cti claim in stored token including an exi claim");
throw new AceException("Malformed cti claim in stored token including an exi claim");
}
if (exiSeqNum <= highestExiSeqNum) {
// This non-expired Access Tokens includes the 'exi' claim and
// its Sequence Number is smaller than the highest Sequence Number
// previously identified. Hence, it must also be removed.
tokenToRemove.add(foo.getKey());
}
}
}
}
// Delete the non-expired Access Tokens including the 'exi' claim
for (String cti : tokenToRemove) {
removeToken(cti);
}
}
}
/**
* Check if there is a token allowing access.
*
* @param kid the key identifier used for proof-of-possession.
* @param subject the authenticated subject if there is any, can be null
* @param resource the resource that is accessed
* @param action the RESTful action code.
* @param intro the introspection handler, can be null
* @return 1 if there is a token giving access, 0 if there is no token
* for this resource and user,-1 if the existing token(s) do not authorize
* the action requested.
* @throws AceException
* @throws IntrospectionException
*/
public int canAccess(String kid, String subject, String resource,
short action, IntrospectionHandler intro)
throws AceException, IntrospectionException {
//Expunge expired tokens
purgeTokens();
//Check if we have tokens for this pop-key
if (!this.cti2kid.containsValue(kid)) {
return UNAUTHZ; //No tokens for this pop-key
}
//Collect the token id's of matching tokens
Set<String> ctis = new HashSet<>();
for (String cti : this.cti2kid.keySet()) {
if (this.cti2kid.get(cti).equals(kid)) {
ctis.add(cti);
}
}
boolean methodNA = false;
for (String cti : ctis) { //All tokens linked to that pop key
//Check if we have the claims for that cti
//Get the claims
Map<Short, CBORObject> claims = this.cti2claims.get(cti);
if (claims == null || claims.isEmpty()) {
//No claims found
continue;
}
//Check if the subject matches
CBORObject subO = claims.get(Constants.SUB);
if (subO != null) {
if (subject == null) {
//Token requires subject, but none provided
continue;
}
if (!subO.AsString().equals(subject)) {
//Token doesn't match subject
continue;
}
}
//Check if the token is expired
CBORObject exp = claims.get(Constants.EXP);
if (exp != null && !(exp.isNumber() && exp.AsNumber().IsInteger())) {
throw new AceException(
"Expiration time is in wrong format");
}
if (exp != null && exp.AsNumber().ToInt64Checked() < this.time.getCurrentTime()) {
//Token is expired
continue;
}
//Check nbf
CBORObject nbf = claims.get(Constants.NBF);
if (nbf != null && !(nbf.isNumber() && nbf.AsNumber().IsInteger())) {
throw new AceException("NotBefore time is in wrong format");
}
if (nbf != null && nbf.AsNumber().ToInt64Checked() > this.time.getCurrentTime()) {
//Token not valid yet
continue;
}
//Check the scope
CBORObject scope = claims.get(Constants.SCOPE);
if (scope == null) {
LOGGER.severe("Token: " + cti + " has no scope");
throw new AceException("Token: " + cti + " has no scope");
}
if (this.scopeValidator.scopeMatchResource(scope, resource)) {
if (this.scopeValidator.scopeMatch(scope, resource, action)) {
//Check if we should introspect this token
if (intro != null) {
byte[] ctiB = Base64.getDecoder().decode(cti);
Map<Short,CBORObject> introspect = intro.getParams(ctiB);
if (introspect != null
&& introspect.get(Constants.ACTIVE) == null) {
throw new AceException("Token introspection didn't "
+ "return an 'active' parameter");
}
if (introspect != null && introspect.get(
Constants.ACTIVE).isTrue()) {
return OK; // Token is active and passed all other tests
}
} else {
//We didn't introspect, but the token is ok otherwise
return OK;
}
}
methodNA = true; //scope did match resource but not action
}
}
return ((methodNA) ? METHODNA : FORBID);
}
/**
* Save the current tokens in a JSON file
* @throws AceException
*/
private void persist() throws AceException {
JSONArray config = new JSONArray();
for (String cti : this.cti2claims.keySet()) {
Map<Short, CBORObject> claims = this.cti2claims.get(cti);
JSONObject token = new JSONObject();
for (Map.Entry<Short,CBORObject> entry : claims.entrySet()) {
token.put(entry.getKey().toString(),
Base64.getEncoder().encodeToString(
entry.getValue().EncodeToBytes()));
}
config.put(token);
}
try (FileOutputStream fos
= new FileOutputStream(this.tokenFile, false)) {
fos.write(config.toString(4).getBytes(Constants.charset));
fos.close();
} catch (JSONException | IOException e) {
throw new AceException(e.getMessage());
}
}
/**
* Get the proof-of-possession key of a token identified by its 'cti'.
*
* @param cti the cti of the token Base64 encoded
*
* @return the pop-key the token or null if this cti is unknown
* @throws AceException
*/
public OneKey getPoP(String cti) throws AceException {
if (cti != null) {
purgeTokens();
String kid = this.cti2kid.get(cti);
OneKey key = this.kid2key.get(kid);
if (key == null) {
LOGGER.finest("Token with cti: " + cti
+ " not found in getPoP()");
return null;
}
return key;
}
LOGGER.severe("getCnf() called with null cti");
throw new AceException("Must supply non-null cti to get cnf");
}
/**
* Get a key identified by it's 'kid'.
*
* @param kid the kid of the key
*
* @return the key identified by this kid of null if we don't have it
*
* @throws AceException
*/
public OneKey getKey(String kid) throws AceException {
if (kid != null) {
OneKey key = this.kid2key.get(kid);
if (key == null) {
LOGGER.finest("Key with kid: " + kid
+ " not found in getKey()");
return null;
}
return key;
}
LOGGER.severe("getKey() called with null kid");
throw new AceException("Must supply non-null kid to get key");
}
/**
* Get the kid by the subject id.
*
* @param sid the subject id
*
* @return the kid this subject uses
*/
public String getKid(String sid) {
if (sid != null) {
return this.sid2kid.get(sid);
}
LOGGER.finest("Key-Id for Subject-Id: " + sid + " not found");
return null;
}
/**
* Get the kid by the CTI.
*
* @param sid the CTI
*
* @return the kid associated to this CTI
*/
public String getKidByCti(String cti) {
if (cti != null) {
return this.cti2kid.get(cti);
}
LOGGER.finest("Key-Id for CTI: " + cti + " not found");
return null;
}
/**
* Get the subject id by the kid.
*
* @param kid the kid this subject uses
*
* @return the subject id
*/
public String getSid(String kid) {
if (kid != null) {
for (String foo : this.sid2kid.keySet()) {
if (this.sid2kid.get(foo).equals(kid)) {
return foo;
}
}
}
return null;
}
/**
* Get the CTI by the subject id.
*
* @param sid the subject id
*
* @return the CTI associated to the subject id
*/
public String getCti(String sid) {
if (sid != null) {
return sid2cti.get(sid);
}
return null;
}
/**
* Get the OSCORE Input Material ID by the subject id.
*
* @param sid the subject id
*
* @return the OSCORE Input Material ID
*/
public String getOscoreId(String sid) {
if (sid != null) {
return sid2id.get(sid);
}
return null;
}
/**
* FIXME
* @param sid FIXME
* @param rsNonce FIXME
*/
public synchronized void setRsnonce(String sid, String rsNonce) {
if (sid != null && rsNonce != null) {
this.sid2rsnonce.put(sid, rsNonce);
}
}
/**
* FIXME
* @param sid FIXME
* @return FIXME
*/
public synchronized String getRsnonce(String sid) {
if (sid != null) {
return this.sid2rsnonce.get(sid);
}
LOGGER.finest("rsnonce for Subject-Id: " + sid + " not found");
return null;
}
@Override
public synchronized void close() throws AceException {
if (!this.closed) {
this.closed = true;
persist();
singleton = null;
}
}
/**
* @return a set of all token ids (cti) stored in this repository
*/
public Set<String> getCtis() {
return new HashSet<>(this.cti2claims.keySet());
}
/**
* @param kid the key identifier associated to the token ids (cti) of interest
* @return a set of all token ids (cti) stored in this repository and associated to 'kid'
*/
public Set<String> getCtis(String kid) {
//Check if we have tokens for this pop-key
if (!this.cti2kid.containsValue(kid)) {
return null; //No tokens for this pop-key
}
//Collect the token id's of matching tokens
Set<String> ctis = new HashSet<>();
for (String cti : this.cti2kid.keySet()) {
if (this.cti2kid.get(cti).equals(kid)) {
ctis.add(cti);
}
}
return ctis;
}
/**
* Checks if a given scope is meaningful for this repository.
*
* @param scope the Scope, as a CBOR text string or a CBOR byte string
* @return true if the scope is meaningful, false otherwise
* @throws AceException
*/
public boolean checkScope(CBORObject scope) throws AceException {
return this.scopeValidator.isScopeMeaningful(scope);
}
/**
* Returns the necessary scope to perform the given action on the given
* resource.
*
* @param resource the resource
* @param action the action
* @return the scope necessary to perform the action on the resource
*/
public CBORObject getScope(String resource, short action) {
return this.scopeValidator.getScope(resource, action);
}
/**
* Checks if a given scope is meaningful for this repository.
*
* @param scope the Scope, as a CBOR text string or a CBOR byte string
* @param aud the Audience as a CBOR text string
* @return true if the scope is meaningful, false otherwise
* @throws AceException
*/
public boolean checkScope(CBORObject scope, String aud) throws AceException {
return this.scopeValidator.isScopeMeaningful(scope, aud);
}
/**
* Get the claims of a token identified by its 'cti'.
*
* @param cti the cti of the token Base64 encoded
*
* @return the claims of the token
*/
public Map<Short, CBORObject> getClaims(String cti) {
return this.cti2claims.get(cti);
}
/**
* Retrieve the Exi Sequence Number value, encoded in the 'cti'
* claim of an Access Token that includes the 'exi' claim
*
* @param the 'cti' claim included in the Access Token
* @return It returns a positive integer if the Sequence Number is successfully extracted from the 'cti' claim
* It returns -1 in case of error while parsing the 'cti' claim
*
*/
public int getExiSeqNumFromCti(byte[] cti) {
// Retrieve the raw CTI value, as a text string that concatenates:
// - the identifier of the Resource Server
// - the text-encoded Sequence Number used for this Access Token,
// as issued to this Resource Server and including the 'exi' claim
String rawCti = new String(cti);
// Check that the retrieved 'cti' value has a minimum length
int rawCtiLen = rawCti.length();
int rsIdLen = this.rsId.length();
if (rawCtiLen < (rsIdLen + 1)) {
// The 'cti' claim is malformed - It is too short in size
return -1;
}
// Check that the first part of the retrieved 'cti' coincides with the identifier of the Resource Server
String receivedRsId = rawCti.substring(0, rsIdLen);
if (receivedRsId.compareTo(this.rsId) != 0) {
// The 'cti' claim is malformed - The Resource Server Identifier does not match with the expected one
return -1;
}
// Check that the text-encoded Sequence Number is not greater than the stored highest Sequence Number
int seqNum;
String seqNumStr = rawCti.substring(rsIdLen, rawCtiLen);
try {
seqNum = Integer.parseInt(seqNumStr);
}
catch (NumberFormatException e) {
// The 'cti' claim is malformed - The Sequence Number is not encoded as a parsable integer
return -1;
}
return seqNum;
}
/**
* Retrieve the highest Exi Sequence Number value, related
* to received Access Tokens that include the 'exi' claim
*
*/
public synchronized int getTopExiSequenceNumber() {
return this.topExiSequenceNumber;
}
/**
* Set the value of the highest Exi Sequence Number value, related
* to received Access Tokens that include the 'exi' claim
*
* @param seqNum The new highest Exi Sequence Number value
*/
private synchronized void setTopExiSequenceNumber(int seqNum) {
if (seqNum > this.topExiSequenceNumber) {
this.topExiSequenceNumber = seqNum;
}
}
}