/*
* Copyright 2014-2016 CyberVision, Inc.
*
* 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 org.kaaproject.kaa.client.persistence;
import org.apache.avro.io.BinaryDecoder;
import org.apache.avro.io.BinaryEncoder;
import org.apache.avro.io.DecoderFactory;
import org.apache.avro.io.EncoderFactory;
import org.apache.avro.specific.SpecificDatumReader;
import org.apache.avro.specific.SpecificDatumWriter;
import org.apache.commons.compress.utils.Charsets;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.kaaproject.kaa.client.KaaClientProperties;
import org.kaaproject.kaa.client.event.EndpointAccessToken;
import org.kaaproject.kaa.client.event.EndpointKeyHash;
import org.kaaproject.kaa.client.exceptions.KaaRuntimeException;
import org.kaaproject.kaa.client.notification.TopicListHashCalculator;
import org.kaaproject.kaa.client.util.Base64;
import org.kaaproject.kaa.common.endpoint.gen.SubscriptionType;
import org.kaaproject.kaa.common.endpoint.gen.Topic;
import org.kaaproject.kaa.common.endpoint.security.KeyUtil;
import org.kaaproject.kaa.common.hash.EndpointObjectHash;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.security.InvalidKeyException;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
public class KaaClientPropertiesState implements KaaClientState {
private static final String APP_STATE_SEQ_NUMBER = "APP_STATE_SEQ_NUMBER";
private static final String PROFILE_HASH = "PROFILE_HASH";
private static final String ENDPOINT_ACCESS_TOKEN = "ENDPOINT_TOKEN";
/**
* The Constant LOG.
*/
private static final Logger LOG = LoggerFactory.getLogger(KaaClientPropertiesState.class);
private static final String ATTACHED_ENDPOINTS = "attached_eps";
private static final String NF_SUBSCRIPTIONS = "nf_subscriptions";
private static final String IS_REGISTERED = "is_registered";
private static final String IS_ATTACHED = "is_attached";
private static final String EVENT_SEQ_NUM = "event.seq.num";
private static final String TOPIC_LIST = "topic.list";
private static final String TOPIC_LIST_HASH = "topic.list.hash";
private static final String PROPERTIES_HASH = "properties.hash";
private static final String NEED_PROFILE_RESYNC = "need.profile.resync";
private final PersistentStorage storage;
private final Base64 base64;
private final Properties state;
private final String stateFileLocation;
private final String clientPrivateKeyFileLocation;
private final String clientPublicKeyFileLocation;
private final Map<Long, Topic> topicMap = new HashMap<>();
private final Map<Long, Integer> nfSubscriptions = new HashMap<>();
private final Map<EndpointAccessToken, EndpointKeyHash> attachedEndpoints = new HashMap<>();
private final AtomicInteger eventSequence = new AtomicInteger();
private Integer topicListHash;
private KeyPair keyPair;
private EndpointKeyHash keyHash;
private boolean isConfigVersionUpdated = false;
private boolean hasUpdate = false;
private boolean isAutogeneratedKeys;
public KaaClientPropertiesState(PersistentStorage storage, Base64 base64,
KaaClientProperties properties) {
this(storage, base64, properties, false);
}
/**
* All needed properties for creating Kaa client
*
* @param storage - in what storage platform will work
* @param base64 - interface for Base64 type
* @param properties - saved properties for client
* @param isAutogeneratedKeys - if not key pair keyPair and true, then create keys. In default it
* false and used already created keys.
*/
public KaaClientPropertiesState(PersistentStorage storage, Base64 base64,
KaaClientProperties properties, boolean isAutogeneratedKeys) {
super();
this.storage = storage;
this.base64 = base64;
this.isAutogeneratedKeys = isAutogeneratedKeys;
properties.setBase64(base64);
stateFileLocation = properties.getStateFileFullName();
clientPrivateKeyFileLocation = properties.getPrivateKeyFileFullName();
clientPublicKeyFileLocation = properties.getPublicKeyFileFullName();
LOG.info("Version: '{}', commit hash: '{}'", properties.getBuildVersion(),
properties.getCommitHash());
state = new Properties();
if (storage.exists(stateFileLocation)) {
InputStream stream = null;
try {
stream = storage.openForRead(stateFileLocation);
state.load(stream);
if (isSdkPropertiesUpdated(properties)) {
LOG.info("SDK properties were updated");
setRegistered(false);
setPropertiesHash(properties.getPropertiesHash());
//TODO: add more intelligent check by comparing part of SDK token.
isConfigVersionUpdated = true;
} else {
LOG.info("SDK properties are up to date");
}
parseTopics();
parseNfSubscriptions();
String attachedEndpointsString = state.getProperty(ATTACHED_ENDPOINTS);
if (attachedEndpointsString != null) {
String[] splittedEndpointsList = attachedEndpointsString.split(",");
for (String attachedEndpoint : splittedEndpointsList) {
if (!attachedEndpoint.isEmpty()) {
String[] splittedValues = attachedEndpoint.split(":");
attachedEndpoints.put(new EndpointAccessToken(splittedValues[0]),
new EndpointKeyHash(splittedValues[1]));
}
}
}
String eventSeqNumStr = state.getProperty(EVENT_SEQ_NUM);
if (eventSeqNumStr != null) {
Integer eventSeqNum = 0;
try { // NOSONAR
eventSeqNum = Integer.parseInt(eventSeqNumStr);
} catch (NumberFormatException ex) {
LOG.error("Unexpected exception while parsing event sequence number. "
+ "Can not parse String: {} to Integer", eventSeqNumStr);
}
eventSequence.set(eventSeqNum);
}
String topicListHashStr = state.getProperty(TOPIC_LIST_HASH);
if (topicListHashStr != null) {
try { // NOSONAR
this.topicListHash = Integer.parseInt(topicListHashStr);
} catch (NumberFormatException ex) {
LOG.error("Unexpected exception while parsing topic list hash. Can not parse String: "
+ "{} to Integer", topicListHashStr);
}
}
} catch (Exception ex) {
LOG.error("Can't load state file", ex);
} finally {
IOUtils.closeQuietly(stream);
}
} else {
LOG.info("First SDK start");
setPropertiesHash(properties.getPropertiesHash());
}
}
private void parseTopics() {
if (state.getProperty(TOPIC_LIST) != null) {
byte[] data = base64.decodeBase64(state.getProperty(TOPIC_LIST));
BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
SpecificDatumReader<Topic> avroReader = new SpecificDatumReader<>(Topic.class);
try { // NOSONAR
Topic decodedTopic;
while (!decoder.isEnd()) {
decodedTopic = avroReader.read(null, decoder);
LOG.debug("Loaded {}", decodedTopic);
topicMap.put(decodedTopic.getId(), decodedTopic);
}
} catch (Exception ex) {
LOG.error("Unexpected exception occurred while reading information from decoder", ex);
}
} else {
LOG.info("No topic list found in state");
}
}
@SuppressWarnings("unchecked")
private void parseNfSubscriptions() {
if (state.getProperty(NF_SUBSCRIPTIONS) != null) {
byte[] data = base64.decodeBase64(state.getProperty(NF_SUBSCRIPTIONS));
ByteArrayInputStream is = new ByteArrayInputStream(data);
try (ObjectInputStream ois = new ObjectInputStream(is)) {
nfSubscriptions.putAll((Map<Long, Integer>) ois.readObject());
} catch (Exception ex) {
LOG.error("Unexpected exception occurred while reading subscription information from state",
ex);
}
} else {
LOG.info("No subscription info found in state");
}
}
private boolean isSdkPropertiesUpdated(KaaClientProperties sdkProperties) {
byte[] hashFromSdk = sdkProperties.getPropertiesHash();
byte[] hashFromStateFile = base64.decodeBase64(state.getProperty(PROPERTIES_HASH,
new String(base64.encodeBase64(new byte[0]), Charsets.UTF_8)).getBytes(Charsets.UTF_8));
return !Arrays.equals(hashFromSdk, hashFromStateFile);
}
private void setStateStringValue(String propertyKey, String value) {
Object previous = state.setProperty(propertyKey, value);
String previousString = previous == null ? null : previous.toString();
hasUpdate |= !value.equals(previousString);
}
private void setStateBooleanValue(String propertyKey, boolean value) {
Object previous = state.setProperty(propertyKey, Boolean.toString(value));
boolean previousBoolean = previous == null ? false : Boolean.valueOf(previous.toString());
hasUpdate |= value != previousBoolean;
}
private void setPropertiesHash(byte[] hash) {
setStateStringValue(PROPERTIES_HASH, new String(base64.encodeBase64(hash), Charsets.UTF_8));
}
@Override
public boolean isConfigurationVersionUpdated() {
return isConfigVersionUpdated;
}
@Override
public boolean isRegistered() {
return Boolean.parseBoolean(state.getProperty(IS_REGISTERED, Boolean.FALSE.toString()));
}
@Override
public void setRegistered(boolean registered) {
setStateBooleanValue(IS_REGISTERED, registered);
}
@Override
public boolean isNeedProfileResync() {
return Boolean.parseBoolean(state.getProperty(NEED_PROFILE_RESYNC, Boolean.FALSE.toString()));
}
@Override
public void setIfNeedProfileResync(boolean needProfileResync) {
setStateBooleanValue(NEED_PROFILE_RESYNC, needProfileResync);
}
@Override
public void persist() {
if (hasUpdate) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(baos, null);
SpecificDatumWriter<Topic> datumWriter = new SpecificDatumWriter<>(Topic.class);
try {
for (Topic topic : topicMap.values()) {
datumWriter.write(topic, encoder);
LOG.info("Persisted {}", topic);
}
encoder.flush();
String base64Str = new String(base64.encodeBase64(baos.toByteArray()),
Charset.forName("UTF-8"));
state.setProperty(TOPIC_LIST, base64Str);
} catch (IOException ex) {
LOG.error("Can't persist topic list info", ex);
}
baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(nfSubscriptions);
String base64Str = new String(base64.encodeBase64(baos.toByteArray()),
Charset.forName("UTF-8"));
state.setProperty(NF_SUBSCRIPTIONS, base64Str);
} catch (IOException ex) {
LOG.error("Can't persist notification subscription info", ex);
}
StringBuilder attachedEndpointsString = new StringBuilder();
for (Map.Entry<EndpointAccessToken, EndpointKeyHash> attached : attachedEndpoints
.entrySet()) {
attachedEndpointsString.append(attached.getKey().getToken()).append(":")
.append(attached.getValue().getKeyHash()).append(',');
}
state.setProperty(ATTACHED_ENDPOINTS, attachedEndpointsString.toString());
state.setProperty(EVENT_SEQ_NUM, "" + eventSequence.get());
if (topicListHash != null) {
state.setProperty(TOPIC_LIST_HASH, "" + topicListHash);
}
OutputStream os = null;
try {
storage.renameTo(stateFileLocation, stateFileLocation + "_bckp");
os = storage.openForWrite(stateFileLocation);
state.store(os, null);
hasUpdate = false;
} catch (IOException ex) {
LOG.error("Can't persist state file", ex);
} finally {
IOUtils.closeQuietly(os);
}
}
}
@Override
public String refreshEndpointAccessToken() {
String newAccessToken = UUID.randomUUID().toString();
setEndpointAccessToken(newAccessToken);
return newAccessToken;
}
@Override
public PrivateKey getPrivateKey() {
return getOrInitKeyPair(isAutogeneratedKeys).getPrivate();
}
@Override
public PublicKey getPublicKey() {
return getOrInitKeyPair(isAutogeneratedKeys).getPublic();
}
private KeyPair getOrInitKeyPair(boolean isAutogeneratedKeys) {
LOG.debug("Check if key pair exists {}, {}",
clientPublicKeyFileLocation, clientPrivateKeyFileLocation);
if (keyPair != null) {
return keyPair;
}
if (storage.exists(clientPublicKeyFileLocation)
&& storage.exists(clientPrivateKeyFileLocation)) {
InputStream publicKeyInput = null;
InputStream privateKeyInput = null;
try {
publicKeyInput = storage.openForRead(clientPublicKeyFileLocation);
privateKeyInput = storage.openForRead(clientPrivateKeyFileLocation);
PublicKey publicKey = KeyUtil.getPublic(publicKeyInput);
PrivateKey privateKey = KeyUtil.getPrivate(privateKeyInput);
if (publicKey != null && privateKey != null) {
keyPair = new KeyPair(publicKey, privateKey);
if (!KeyUtil.validateKeyPair(keyPair)) {
throw new InvalidKeyException();
}
return keyPair;
}
} catch (InvalidKeyException ex) {
keyPair = null;
LOG.error("Unable to parse client RSA keypair. Generating new keys.. Reason {}", ex);
} catch (Exception ex) {
LOG.error("Error loading client RSA keypair. Reason {}", ex);
throw new RuntimeException(ex); // NOSONAR
} finally {
IOUtils.closeQuietly(publicKeyInput);
IOUtils.closeQuietly(privateKeyInput);
}
}
if (isAutogeneratedKeys) {
LOG.debug("Generating Client Key pair");
OutputStream privateKeyOutput = null;
OutputStream publicKeyOutput = null;
try {
privateKeyOutput = storage.openForWrite(clientPrivateKeyFileLocation);
publicKeyOutput = storage.openForWrite(clientPublicKeyFileLocation);
keyPair = KeyUtil.generateKeyPair(privateKeyOutput, publicKeyOutput);
} catch (IOException ex) {
LOG.error("Error generating Client Key pair", ex);
throw new RuntimeException(ex);
} finally {
IOUtils.closeQuietly(privateKeyOutput);
IOUtils.closeQuietly(publicKeyOutput);
}
} else {
LOG.debug("Error loading key pair!", "Key pair is not found and key strategy is default");
throw new KaaRuntimeException("Key pair is not found and your key strategy is default");
}
return keyPair;
}
@Override
public EndpointKeyHash getEndpointKeyHash() {
if (keyHash == null) {
EndpointObjectHash publicKeyHash = EndpointObjectHash
.fromSha1(getOrInitKeyPair(isAutogeneratedKeys).getPublic().getEncoded());
keyHash = new EndpointKeyHash(new String(base64.encodeBase64(publicKeyHash.getData())));
}
return keyHash;
}
@Override
public int getAppStateSeqNumber() {
return Integer.parseInt(state.getProperty(APP_STATE_SEQ_NUMBER, "1"));
}
@Override
public void setAppStateSeqNumber(int appStateSeqNumber) {
setStateStringValue(APP_STATE_SEQ_NUMBER, Integer.toString(appStateSeqNumber));
}
@Override
public EndpointObjectHash getProfileHash() {
return EndpointObjectHash.fromBytes(base64.decodeBase64(state.getProperty(PROFILE_HASH,
new String(base64.encodeBase64(new byte[0]), Charsets.UTF_8)).getBytes(Charsets.UTF_8)));
}
@Override
public void setProfileHash(EndpointObjectHash hash) {
setStateStringValue(PROFILE_HASH,
new String(base64.encodeBase64(hash.getData()), Charsets.UTF_8));
}
@Override
public void addTopic(Topic topic) {
if (topicMap.get(topic.getId()) == null) {
topicMap.put(topic.getId(), topic);
if (topic.getSubscriptionType() == SubscriptionType.MANDATORY_SUBSCRIPTION) {
nfSubscriptions.put(topic.getId(), 0);
LOG.info("Adding new seqNumber 0 for {} subscription", topic.getId());
}
hasUpdate = true;
LOG.info("Adding new topic with id {}", topic.getId());
}
}
@Override
public void removeTopic(Long topicId) {
if (topicMap.remove(topicId) != null) {
if (nfSubscriptions.remove(topicId) != null) {
LOG.info("Removed subscription info for {}", topicId);
}
hasUpdate = true;
LOG.info("Removed topic with id {}", topicId);
}
}
@Override
public void addTopicSubscription(Long topicId) {
Integer seqNum = nfSubscriptions.get(topicId);
if (seqNum == null) {
nfSubscriptions.put(topicId, 0);
LOG.info("Adding new seqNumber 0 for {} subscription", topicId);
hasUpdate = true;
}
}
@Override
public void removeTopicSubscription(Long topicId) {
if (nfSubscriptions.remove(topicId) != null) {
LOG.info("Removed subscription info for {}", topicId);
hasUpdate = true;
}
}
@Override
public boolean updateTopicSubscriptionInfo(Long topicId, Integer sequenceNumber) {
Integer seqNum = nfSubscriptions.get(topicId);
boolean updated = false;
if (seqNum != null) {
if (sequenceNumber > seqNum) {
updated = true;
nfSubscriptions.put(topicId, sequenceNumber);
hasUpdate = true;
LOG.debug("Updated seqNumber to {} for {} subscription", sequenceNumber, topicId);
}
}
return updated;
}
@Override
public Map<Long, Integer> getNfSubscriptions() {
return nfSubscriptions;
}
@Override
public Collection<Topic> getTopics() {
return topicMap.values();
}
@Override
public Integer getTopicListHash() {
if (topicListHash == null) {
return TopicListHashCalculator.NULL_LIST_HASH;
} else {
return topicListHash;
}
}
@Override
public void setTopicListHash(Integer topicListHash) {
if (!Objects.equals(this.topicListHash, topicListHash)) {
this.topicListHash = topicListHash;
hasUpdate = true;
}
}
@Override
public Map<EndpointAccessToken, EndpointKeyHash> getAttachedEndpointsList() {
return attachedEndpoints;
}
@Override
public void setAttachedEndpointsList(
Map<EndpointAccessToken, EndpointKeyHash> attachedEndpoints) {
this.attachedEndpoints.clear();
this.attachedEndpoints.putAll(attachedEndpoints);
hasUpdate = true;
}
@Override
public String getEndpointAccessToken() {
return state.getProperty(ENDPOINT_ACCESS_TOKEN, "");
}
@Override
public void setEndpointAccessToken(String token) {
setStateStringValue(ENDPOINT_ACCESS_TOKEN, token);
}
@Override
public int getAndIncrementEventSeqNum() {
hasUpdate = true;
return eventSequence.getAndIncrement();
}
@Override
public int getEventSeqNum() {
return eventSequence.get();
}
@Override
public void setEventSeqNum(int newSeqNum) {
if (eventSequence.get() != newSeqNum) {
eventSequence.set(newSeqNum);
hasUpdate = true;
}
}
@Override
public boolean isAttachedToUser() {
return Boolean.parseBoolean(state.getProperty(IS_ATTACHED, Boolean.FALSE.toString()));
}
@Override
public void setAttachedToUser(boolean isAttached) {
setStateBooleanValue(IS_ATTACHED, isAttached);
}
@Override
public void clean() {
setRegistered(false);
setIfNeedProfileResync(false);
saveFileDelete(stateFileLocation);
saveFileDelete(stateFileLocation + "_bckp");
keyPair = null;
hasUpdate = true;
}
private void saveFileDelete(String fileName) {
try {
FileUtils.forceDelete(new File(fileName));
} catch (FileNotFoundException ex) {
LOG.trace("File {} wasn't deleted, as it hadn't existed :", fileName, ex);
} catch (IOException ex) {
LOG.debug("An error occurred during deletion of the file [{}] :", fileName, ex);
}
}
}