/**
* Copyright 2013 Google 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 com.matthewmitchell.nubitsj.wallet;
import com.matthewmitchell.nubitsj.core.*;
import com.matthewmitchell.nubitsj.crypto.*;
import com.matthewmitchell.nubitsj.params.MainNetParams;
import com.matthewmitchell.nubitsj.params.UnitTestParams;
import com.matthewmitchell.nubitsj.store.UnreadableWalletException;
import com.matthewmitchell.nubitsj.utils.BriefLogFormatter;
import com.matthewmitchell.nubitsj.utils.Threading;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import org.junit.Before;
import org.junit.Test;
import org.spongycastle.crypto.params.KeyParameter;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.List;
import static com.google.common.base.Preconditions.checkNotNull;
import static org.junit.Assert.*;
public class DeterministicKeyChainTest {
private DeterministicKeyChain chain;
private final byte[] ENTROPY = Sha256Hash.hash("don't use a string seed like this in real life".getBytes());
@Before
public void setup() {
BriefLogFormatter.init();
// You should use a random seed instead. The secs constant comes from the unit test file, so we can compare
// serialized data properly.
long secs = 1389353062L;
chain = new DeterministicKeyChain(ENTROPY, "", secs);
chain.setLookaheadSize(10);
assertEquals(secs, checkNotNull(chain.getSeed()).getCreationTimeSeconds());
}
@Test
public void derive() throws Exception {
ECKey key1 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertFalse(key1.isPubKeyOnly());
ECKey key2 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertFalse(key2.isPubKeyOnly());
final Address address = new Address(UnitTestParams.get(), "BQXXhTcB5PrQsizhyDjrQ5qymVKHfB35ey");
assertEquals(address, key1.toAddress(UnitTestParams.get()));
assertEquals("BBDbwVqisLpMkUB8RDa1HQBVAVMjzpARw2", key2.toAddress(UnitTestParams.get()).toString());
assertEquals(key1, chain.findKeyFromPubHash(address.getHash160()));
assertEquals(key2, chain.findKeyFromPubKey(key2.getPubKey()));
key1.sign(Sha256Hash.ZERO_HASH);
assertFalse(key1.isPubKeyOnly());
ECKey key3 = chain.getKey(KeyChain.KeyPurpose.CHANGE);
assertEquals("BEqtcLrSms9ZC6sRt78JQNcphPnTEi136w", key3.toAddress(UnitTestParams.get()).toString());
key3.sign(Sha256Hash.ZERO_HASH);
assertFalse(key3.isPubKeyOnly());
}
@Test
public void getKeys() throws Exception {
chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
chain.getKey(KeyChain.KeyPurpose.CHANGE);
chain.maybeLookAhead();
assertEquals(2, chain.getKeys(false).size());
}
@Test
public void deriveAccountOne() throws Exception {
long secs = 1389353062L;
DeterministicKeyChain chain1 = new AccountOneChain(ENTROPY, "", secs);
ECKey key1 = chain1.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
ECKey key2 = chain1.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
final Address address = new Address(UnitTestParams.get(), "BRiQc5f63wsD7LUTPcv54DA1LHLZA9MyPi");
assertEquals(address, key1.toAddress(UnitTestParams.get()));
assertEquals("BBkA3pMo2UWZen5UGtCtVk83SV3WZDGg5a", key2.toAddress(UnitTestParams.get()).toString());
assertEquals(key1, chain1.findKeyFromPubHash(address.getHash160()));
assertEquals(key2, chain1.findKeyFromPubKey(key2.getPubKey()));
key1.sign(Sha256Hash.ZERO_HASH);
ECKey key3 = chain1.getKey(KeyChain.KeyPurpose.CHANGE);
assertEquals("BDfZ2QNGoQdmbVoRtXpznm5ksauABFZQ8V", key3.toAddress(UnitTestParams.get()).toString());
key3.sign(Sha256Hash.ZERO_HASH);
}
static class AccountOneChain extends DeterministicKeyChain {
public AccountOneChain(byte[] entropy, String s, long secs) {
super(entropy, s, secs);
}
public AccountOneChain(KeyCrypter crypter, DeterministicSeed seed) {
super(seed, crypter);
}
@Override
protected ImmutableList<ChildNumber> getAccountPath() {
return ImmutableList.of(ChildNumber.ONE);
}
}
@Test
public void serializeAccountOne() throws Exception {
long secs = 1389353062L;
DeterministicKeyChain chain1 = new AccountOneChain(ENTROPY, "", secs);
ECKey key1 = chain1.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
final Address address = new Address(UnitTestParams.get(), "BRiQc5f63wsD7LUTPcv54DA1LHLZA9MyPi");
assertEquals(address, key1.toAddress(UnitTestParams.get()));
DeterministicKey watching = chain1.getWatchingKey();
List<Protos.Key> keys = chain1.serializeToProtobuf();
KeyChainFactory factory = new KeyChainFactory() {
@Override
public DeterministicKeyChain makeKeyChain(Protos.Key key, Protos.Key firstSubKey, DeterministicSeed seed, KeyCrypter crypter, boolean isMarried) {
return new AccountOneChain(crypter, seed);
}
@Override
public DeterministicKeyChain makeWatchingKeyChain(Protos.Key key, Protos.Key firstSubKey, DeterministicKey accountKey, boolean isFollowingKey, boolean isMarried) {
throw new UnsupportedOperationException();
}
};
chain1 = DeterministicKeyChain.fromProtobuf(keys, null, factory).get(0);
ECKey key2 = chain1.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertEquals("BBkA3pMo2UWZen5UGtCtVk83SV3WZDGg5a", key2.toAddress(UnitTestParams.get()).toString());
assertEquals(key1, chain1.findKeyFromPubHash(address.getHash160()));
assertEquals(key2, chain1.findKeyFromPubKey(key2.getPubKey()));
key1.sign(Sha256Hash.ZERO_HASH);
ECKey key3 = chain1.getKey(KeyChain.KeyPurpose.CHANGE);
assertEquals("BDfZ2QNGoQdmbVoRtXpznm5ksauABFZQ8V", key3.toAddress(UnitTestParams.get()).toString());
key3.sign(Sha256Hash.ZERO_HASH);
assertEquals(watching, chain1.getWatchingKey());
}
@Test
public void signMessage() throws Exception {
ECKey key = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
key.verifyMessage("test", key.signMessage("test"));
}
@Test
public void events() throws Exception {
// Check that we get the right events at the right time.
final List<List<ECKey>> listenerKeys = Lists.newArrayList();
long secs = 1389353062L;
chain = new DeterministicKeyChain(ENTROPY, "", secs);
chain.addEventListener(new AbstractKeyChainEventListener() {
@Override
public void onKeysAdded(List<ECKey> keys) {
listenerKeys.add(keys);
}
}, Threading.SAME_THREAD);
assertEquals(0, listenerKeys.size());
chain.setLookaheadSize(5);
assertEquals(0, listenerKeys.size());
ECKey key = chain.getKey(KeyChain.KeyPurpose.CHANGE);
assertEquals(1, listenerKeys.size()); // 1 event
final List<ECKey> firstEvent = listenerKeys.get(0);
assertEquals(1, firstEvent.size());
assertTrue(firstEvent.contains(key)); // order is not specified.
listenerKeys.clear();
chain.maybeLookAhead();
final List<ECKey> secondEvent = listenerKeys.get(0);
assertEquals(12, secondEvent.size()); // (5 lookahead keys, +1 lookahead threshold) * 2 chains
listenerKeys.clear();
chain.getKey(KeyChain.KeyPurpose.CHANGE);
// At this point we've entered the threshold zone so more keys won't immediately trigger more generations.
assertEquals(0, listenerKeys.size()); // 1 event
final int lookaheadThreshold = chain.getLookaheadThreshold() + chain.getLookaheadSize();
for (int i = 0; i < lookaheadThreshold; i++)
chain.getKey(KeyChain.KeyPurpose.CHANGE);
assertEquals(1, listenerKeys.size()); // 1 event
assertEquals(1, listenerKeys.get(0).size()); // 1 key.
}
@Test
public void random() {
// Can't test much here but verify the constructor worked and the class is functional. The other tests rely on
// a fixed seed to be deterministic.
chain = new DeterministicKeyChain(new SecureRandom(), 384);
chain.setLookaheadSize(10);
chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS).sign(Sha256Hash.ZERO_HASH);
chain.getKey(KeyChain.KeyPurpose.CHANGE).sign(Sha256Hash.ZERO_HASH);
}
@Test
public void serializeUnencrypted() throws UnreadableWalletException {
chain.maybeLookAhead();
DeterministicKey key1 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
DeterministicKey key2 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
DeterministicKey key3 = chain.getKey(KeyChain.KeyPurpose.CHANGE);
List<Protos.Key> keys = chain.serializeToProtobuf();
// 1 mnemonic/seed, 1 master key, 1 account key, 2 internal keys, 3 derived, 20 lookahead and 5 lookahead threshold.
int numItems =
1 // mnemonic/seed
+ 1 // master key
+ 1 // account key
+ 2 // ext/int parent keys
+ (chain.getLookaheadSize() + chain.getLookaheadThreshold()) * 2 // lookahead zone on each chain
;
assertEquals(numItems, keys.size());
// Get another key that will be lost during round-tripping, to ensure we can derive it again.
DeterministicKey key4 = chain.getKey(KeyChain.KeyPurpose.CHANGE);
final String EXPECTED_SERIALIZATION = checkSerialization(keys, "deterministic-wallet-serialization.txt");
// Round trip the data back and forth to check it is preserved.
int oldLookaheadSize = chain.getLookaheadSize();
chain = DeterministicKeyChain.fromProtobuf(keys, null).get(0);
assertEquals(EXPECTED_SERIALIZATION, protoToString(chain.serializeToProtobuf()));
assertEquals(key1, chain.findKeyFromPubHash(key1.getPubKeyHash()));
assertEquals(key2, chain.findKeyFromPubHash(key2.getPubKeyHash()));
assertEquals(key3, chain.findKeyFromPubHash(key3.getPubKeyHash()));
assertEquals(key4, chain.getKey(KeyChain.KeyPurpose.CHANGE));
key1.sign(Sha256Hash.ZERO_HASH);
key2.sign(Sha256Hash.ZERO_HASH);
key3.sign(Sha256Hash.ZERO_HASH);
key4.sign(Sha256Hash.ZERO_HASH);
assertEquals(oldLookaheadSize, chain.getLookaheadSize());
}
@Test(expected = IllegalStateException.class)
public void notEncrypted() {
chain.toDecrypted("fail");
}
@Test(expected = IllegalStateException.class)
public void encryptTwice() {
chain = chain.toEncrypted("once");
chain = chain.toEncrypted("twice");
}
private void checkEncryptedKeyChain(DeterministicKeyChain encChain, DeterministicKey key1) {
// Check we can look keys up and extend the chain without the AES key being provided.
DeterministicKey encKey1 = encChain.findKeyFromPubKey(key1.getPubKey());
DeterministicKey encKey2 = encChain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertFalse(key1.isEncrypted());
assertTrue(encKey1.isEncrypted());
assertEquals(encKey1.getPubKeyPoint(), key1.getPubKeyPoint());
final KeyParameter aesKey = checkNotNull(encChain.getKeyCrypter()).deriveKey("open secret");
encKey1.sign(Sha256Hash.ZERO_HASH, aesKey);
encKey2.sign(Sha256Hash.ZERO_HASH, aesKey);
assertTrue(encChain.checkAESKey(aesKey));
assertFalse(encChain.checkPassword("access denied"));
assertTrue(encChain.checkPassword("open secret"));
}
@Test
public void encryption() throws UnreadableWalletException {
DeterministicKey key1 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
DeterministicKeyChain encChain = chain.toEncrypted("open secret");
DeterministicKey encKey1 = encChain.findKeyFromPubKey(key1.getPubKey());
checkEncryptedKeyChain(encChain, key1);
// Round-trip to ensure de/serialization works and that we can store two chains and they both deserialize.
List<Protos.Key> serialized = encChain.serializeToProtobuf();
List<Protos.Key> doubled = Lists.newArrayListWithExpectedSize(serialized.size() * 2);
doubled.addAll(serialized);
doubled.addAll(serialized);
final List<DeterministicKeyChain> chains = DeterministicKeyChain.fromProtobuf(doubled, encChain.getKeyCrypter());
assertEquals(2, chains.size());
encChain = chains.get(0);
checkEncryptedKeyChain(encChain, chain.findKeyFromPubKey(key1.getPubKey()));
encChain = chains.get(1);
checkEncryptedKeyChain(encChain, chain.findKeyFromPubKey(key1.getPubKey()));
DeterministicKey encKey2 = encChain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
// Decrypt and check the keys match.
DeterministicKeyChain decChain = encChain.toDecrypted("open secret");
DeterministicKey decKey1 = decChain.findKeyFromPubHash(encKey1.getPubKeyHash());
DeterministicKey decKey2 = decChain.findKeyFromPubHash(encKey2.getPubKeyHash());
assertEquals(decKey1.getPubKeyPoint(), encKey1.getPubKeyPoint());
assertEquals(decKey2.getPubKeyPoint(), encKey2.getPubKeyPoint());
assertFalse(decKey1.isEncrypted());
assertFalse(decKey2.isEncrypted());
assertNotEquals(encKey1.getParent(), decKey1.getParent()); // parts of a different hierarchy
// Check we can once again derive keys from the decrypted chain.
decChain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS).sign(Sha256Hash.ZERO_HASH);
decChain.getKey(KeyChain.KeyPurpose.CHANGE).sign(Sha256Hash.ZERO_HASH);
}
@Test
public void watchingChain() throws UnreadableWalletException {
Utils.setMockClock();
DeterministicKey key1 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
DeterministicKey key2 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
DeterministicKey key3 = chain.getKey(KeyChain.KeyPurpose.CHANGE);
DeterministicKey key4 = chain.getKey(KeyChain.KeyPurpose.CHANGE);
NetworkParameters params = MainNetParams.get();
DeterministicKey watchingKey = chain.getWatchingKey();
final String pub58 = watchingKey.serializePubB58(params);
assertEquals("xpub69KR9epSNBM59KLuasxMU5CyKytMJjBP5HEZ5p8YoGUCpM6cM9hqxB9DDPCpUUtqmw5duTckvPfwpoWGQUFPmRLpxs5jYiTf2u6xRMcdhDf", pub58);
watchingKey = DeterministicKey.deserializeB58(null, pub58, params);
watchingKey.setCreationTimeSeconds(100000);
chain = DeterministicKeyChain.watch(watchingKey);
assertEquals(DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS, chain.getEarliestKeyCreationTime());
chain.setLookaheadSize(10);
chain.maybeLookAhead();
assertEquals(key1.getPubKeyPoint(), chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS).getPubKeyPoint());
assertEquals(key2.getPubKeyPoint(), chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS).getPubKeyPoint());
final DeterministicKey key = chain.getKey(KeyChain.KeyPurpose.CHANGE);
assertEquals(key3.getPubKeyPoint(), key.getPubKeyPoint());
try {
// Can't sign with a key from a watching chain.
key.sign(Sha256Hash.ZERO_HASH);
fail();
} catch (ECKey.MissingPrivateKeyException e) {
// Ignored.
}
// Test we can serialize and deserialize a watching chain OK.
List<Protos.Key> serialization = chain.serializeToProtobuf();
checkSerialization(serialization, "watching-wallet-serialization.txt");
chain = DeterministicKeyChain.fromProtobuf(serialization, null).get(0);
final DeterministicKey rekey4 = chain.getKey(KeyChain.KeyPurpose.CHANGE);
assertEquals(key4.getPubKeyPoint(), rekey4.getPubKeyPoint());
}
@Test(expected = IllegalStateException.class)
public void watchingCannotEncrypt() throws Exception {
final DeterministicKey accountKey = chain.getKeyByPath(DeterministicKeyChain.ACCOUNT_ZERO_PATH);
chain = DeterministicKeyChain.watch(accountKey.dropPrivateBytes().dropParent());
chain = chain.toEncrypted("this doesn't make any sense");
}
@Test
public void bloom1() {
DeterministicKey key2 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
DeterministicKey key1 = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
int numEntries =
(((chain.getLookaheadSize() + chain.getLookaheadThreshold()) * 2) // * 2 because of internal/external
+ chain.numLeafKeysIssued()
+ 4 // one root key + one account key + two chain keys (internal/external)
) * 2; // because the filter contains keys and key hashes.
assertEquals(numEntries, chain.numBloomFilterEntries());
BloomFilter filter = chain.getFilter(numEntries, 0.001, 1);
assertTrue(filter.contains(key1.getPubKey()));
assertTrue(filter.contains(key1.getPubKeyHash()));
assertTrue(filter.contains(key2.getPubKey()));
assertTrue(filter.contains(key2.getPubKeyHash()));
// The lookahead zone is tested in bloom2 and via KeyChainGroupTest.bloom
}
@Test
public void bloom2() throws Exception {
// Verify that if when we watch a key, the filter contains at least 100 keys.
DeterministicKey[] keys = new DeterministicKey[100];
for (int i = 0; i < keys.length; i++)
keys[i] = chain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
chain = DeterministicKeyChain.watch(chain.getWatchingKey().dropPrivateBytes().dropParent());
int e = chain.numBloomFilterEntries();
BloomFilter filter = chain.getFilter(e, 0.001, 1);
for (DeterministicKey key : keys)
assertTrue("key " + key, filter.contains(key.getPubKeyHash()));
}
private String protoToString(List<Protos.Key> keys) {
StringBuilder sb = new StringBuilder();
for (Protos.Key key : keys) {
sb.append(key.toString());
sb.append("\n");
}
return sb.toString().trim();
}
private String checkSerialization(List<Protos.Key> keys, String filename) {
try {
String sb = protoToString(keys);
String expected = Utils.getResourceAsString(getClass().getResource(filename));
assertEquals(expected, sb);
return expected;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}