() {
+
+ @Override
+ public int compare(ECKey k1, ECKey k2) {
+ if (k1.creationTimeSeconds == k2.creationTimeSeconds)
+ return 0;
+ else
+ return k1.creationTimeSeconds > k2.creationTimeSeconds ? 1 : -1;
+ }
+ };
+
// The parameters of the secp256k1 curve that Bitcoin uses.
private static final X9ECParameters CURVE_PARAMS = CustomNamedCurves.getByName("secp256k1");
@@ -101,6 +109,9 @@ public class ECKey {
// not have this field.
protected long creationTimeSeconds;
+ protected KeyCrypter keyCrypter;
+ protected EncryptedData encryptedPrivateKey;
+
private byte[] pubKeyHash;
/**
@@ -145,6 +156,18 @@ public class ECKey {
this.pub = pub;
}
+ /**
+ * Constructs a key that has an encrypted private component. The given object wraps encrypted bytes and an
+ * initialization vector. Note that the key will not be decrypted during this call: the returned ECKey is
+ * unusable for signing unless a decryption key is supplied.
+ */
+ public static ECKey fromEncrypted(EncryptedData encryptedPrivateKey, KeyCrypter crypter, byte[] pubKey) {
+ ECKey key = fromPublicOnly(pubKey);
+ key.encryptedPrivateKey = encryptedPrivateKey;
+ key.keyCrypter = crypter;
+ return key;
+ }
+
/**
* Utility for compressing an elliptic curve point. Returns the same point if it's already compressed.
* See the ECKey class docs for a discussion of point compression.
@@ -234,6 +257,19 @@ public class ECKey {
return priv == null;
}
+ /**
+ * Returns true if this key has unencrypted access to private key bytes. Does the opposite of
+ * {@link #isPubKeyOnly()}.
+ */
+ public boolean hasPrivKey() {
+ return priv != null;
+ }
+
+ /** Returns true if this key is watch only, meaning it has a public key but no private key. */
+ public boolean isWatching() {
+ return isPubKeyOnly() && !isEncrypted();
+ }
+
/**
* Output this ECKey as an ASN.1 encoded private key, as understood by OpenSSL or used by Bitcoin Core
* in its wallet storage format.
@@ -313,8 +349,10 @@ public class ECKey {
* @throws java.lang.IllegalStateException if the private key bytes are not available.
*/
public BigInteger getPrivKey() {
- if (priv == null)
+ if (priv == null) {
throw new MissingPrivateKeyException();
+ }
+
return priv;
}
@@ -447,8 +485,35 @@ public class ECKey {
* usually encoded using ASN.1 format, so you want {@link ECKey.ECDSASignature#toASN1()}
* instead. However sometimes the independent components can be useful, for instance, if you're going to do
* further EC maths on them.
+ * @throws KeyCrypterException if this ECKey doesn't have a private part.
*/
- public ECDSASignature sign(Sha256Hash input) {
+ public ECDSASignature sign(Sha256Hash input) throws KeyCrypterException {
+ return sign(input, null);
+ }
+
+ /**
+ * Signs the given hash and returns the R and S components as BigIntegers. In the Bitcoin protocol, they are
+ * usually encoded using DER format, so you want {@link ECKey.ECDSASignature#encodeToDER()}
+ * instead. However sometimes the independent components can be useful, for instance, if you're doing to do further
+ * EC maths on them.
+ *
+ * @param aesKey The AES key to use for decryption of the private key. If null then no decryption is required.
+ * @throws KeyCrypterException if there's something wrong with aesKey.
+ * @throws ECKey.MissingPrivateKeyException if this key cannot sign because it's pubkey only.
+ */
+ public ECDSASignature sign(Sha256Hash input, KeyParameter aesKey) throws KeyCrypterException {
+ KeyCrypter crypter = getKeyCrypter();
+ if (crypter != null) {
+ if (aesKey == null) {
+ throw new KeyIsEncryptedException();
+ }
+ return decrypt(aesKey).sign(input);
+ } else {
+ // No decryption of private key required.
+ if (priv == null) {
+ throw new MissingPrivateKeyException();
+ }
+ }
return doSign(input, priv);
}
@@ -518,6 +583,29 @@ public class ECKey {
return ECKey.verify(sigHash.getBytes(), signature, getPubKey());
}
+ /**
+ * Verifies the given ASN.1 encoded ECDSA signature against a hash using the public key, and throws an exception
+ * if the signature doesn't match
+ * @throws SignatureDecodeException if the signature is unparseable in some way.
+ * @throws java.security.SignatureException if the signature does not match.
+ */
+ public void verifyOrThrow(byte[] hash, byte[] signature) throws SignatureDecodeException, SignatureException {
+ if (!verify(hash, signature)) {
+ throw new SignatureException();
+ }
+ }
+
+ /**
+ * Verifies the given R/S pair (signature) against a hash using the public key, and throws an exception
+ * if the signature doesn't match
+ * @throws java.security.SignatureException if the signature does not match.
+ */
+ public void verifyOrThrow(Sha256Hash sigHash, ECDSASignature signature) throws SignatureException {
+ if (!ECKey.verify(sigHash.getBytes(), signature, getPubKey())) {
+ throw new SignatureException();
+ }
+ }
+
/**
* Returns true if the given pubkey is canonical, i.e. the correct length taking into account compression.
*/
@@ -616,6 +704,184 @@ public class ECKey {
return Utils.bigIntegerToBytes(getPrivKey(), 32);
}
+ /**
+ * Returns the creation time of this key or zero if the key was deserialized from a version that did not store
+ * that data.
+ */
+ @Override
+ public long getCreationTimeSeconds() {
+ return creationTimeSeconds;
+ }
+
+ /**
+ * Sets the creation time of this key. Zero is a convention to mean "unavailable". This method can be useful when
+ * you have a raw key you are importing from somewhere else.
+ */
+ public void setCreationTimeSeconds(long newCreationTimeSeconds) {
+ if (newCreationTimeSeconds < 0) {
+ throw new IllegalArgumentException("Cannot set creation time to negative value: " + newCreationTimeSeconds);
+ }
+ creationTimeSeconds = newCreationTimeSeconds;
+ }
+
+ /**
+ * Create an encrypted private key with the keyCrypter and the AES key supplied.
+ * This method returns a new encrypted key and leaves the original unchanged.
+ *
+ * @param keyCrypter The keyCrypter that specifies exactly how the encrypted bytes are created.
+ * @param aesKey The KeyParameter with the AES encryption key (usually constructed with keyCrypter#deriveKey and cached as it is slow to create).
+ * @return encryptedKey
+ */
+ public ECKey encrypt(KeyCrypter keyCrypter, KeyParameter aesKey) throws KeyCrypterException {
+ if(keyCrypter == null) {
+ throw new KeyCrypterException("Keycrypter cannot be null");
+ }
+
+ final byte[] privKeyBytes = getPrivKeyBytes();
+ EncryptedData encryptedPrivateKey = keyCrypter.encrypt(privKeyBytes, null, aesKey);
+ ECKey result = ECKey.fromEncrypted(encryptedPrivateKey, keyCrypter, getPubKey());
+ result.setCreationTimeSeconds(creationTimeSeconds);
+ return result;
+ }
+
+ /**
+ * Create a decrypted private key with the keyCrypter and AES key supplied. Note that if the aesKey is wrong, this
+ * has some chance of throwing KeyCrypterException due to the corrupted padding that will result, but it can also
+ * just yield a garbage key.
+ *
+ * @param keyCrypter The keyCrypter that specifies exactly how the decrypted bytes are created.
+ * @param aesKey The KeyParameter with the AES encryption key (usually constructed with keyCrypter#deriveKey and cached).
+ */
+ public ECKey decrypt(KeyCrypter keyCrypter, KeyParameter aesKey) throws KeyCrypterException {
+ if(keyCrypter == null) {
+ throw new KeyCrypterException("Keycrypter cannot be null");
+ }
+
+ // Check that the keyCrypter matches the one used to encrypt the keys, if set.
+ if (this.keyCrypter != null && !this.keyCrypter.equals(keyCrypter)) {
+ throw new KeyCrypterException("The keyCrypter being used to decrypt the key is different to the one that was used to encrypt it");
+ }
+
+ if(encryptedPrivateKey == null) {
+ throw new IllegalArgumentException("This key is not encrypted");
+ }
+
+ byte[] unencryptedPrivateKey = keyCrypter.decrypt(encryptedPrivateKey, aesKey);
+ ECKey key = ECKey.fromPrivate(unencryptedPrivateKey);
+
+ if (!Arrays.equals(key.getPubKey(), getPubKey())) {
+ throw new KeyCrypterException("Provided AES key is wrong");
+ }
+
+ key.setCreationTimeSeconds(creationTimeSeconds);
+ return key;
+ }
+
+ /**
+ * Create a decrypted private key with AES key. Note that if the AES key is wrong, this
+ * has some chance of throwing KeyCrypterException due to the corrupted padding that will result, but it can also
+ * just yield a garbage key.
+ *
+ * @param aesKey The KeyParameter with the AES encryption key (usually constructed with keyCrypter#deriveKey and cached).
+ */
+ public ECKey decrypt(KeyParameter aesKey) throws KeyCrypterException {
+ final KeyCrypter crypter = getKeyCrypter();
+ if (crypter == null) {
+ throw new KeyCrypterException("No key crypter available");
+ }
+
+ return decrypt(crypter, aesKey);
+ }
+
+ /**
+ * Creates decrypted private key if needed.
+ */
+ public ECKey maybeDecrypt(KeyParameter aesKey) throws KeyCrypterException {
+ return isEncrypted() && aesKey != null ? decrypt(aesKey) : this;
+ }
+
+ /**
+ * Check that it is possible to decrypt the key with the keyCrypter and that the original key is returned.
+ *
+ * Because it is a critical failure if the private keys cannot be decrypted successfully (resulting of loss of all
+ * bitcoins controlled by the private key) you can use this method to check when you *encrypt* a wallet that
+ * it can definitely be decrypted successfully.
+ *
+ * @return true if the encrypted key can be decrypted back to the original key successfully.
+ */
+ public static boolean encryptionIsReversible(ECKey originalKey, ECKey encryptedKey, KeyCrypter keyCrypter, KeyParameter aesKey) {
+ try {
+ ECKey rebornUnencryptedKey = encryptedKey.decrypt(keyCrypter, aesKey);
+ byte[] originalPrivateKeyBytes = originalKey.getPrivKeyBytes();
+ byte[] rebornKeyBytes = rebornUnencryptedKey.getPrivKeyBytes();
+ if (!Arrays.equals(originalPrivateKeyBytes, rebornKeyBytes)) {
+ log.error("The check that encryption could be reversed failed for {}", originalKey);
+ return false;
+ }
+ return true;
+ } catch (KeyCrypterException kce) {
+ log.error(kce.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Indicates whether the private key is encrypted (true) or not (false).
+ * A private key is deemed to be encrypted when there is both a KeyCrypter and the encryptedPrivateKey is non-zero.
+ */
+ @Override
+ public boolean isEncrypted() {
+ return keyCrypter != null && encryptedPrivateKey != null && encryptedPrivateKey.getEncryptedBytes().length > 0;
+ }
+
+ @Override
+ public EncryptionType getEncryptionType() {
+ return keyCrypter != null ? keyCrypter.getUnderstoodEncryptionType() : EncryptionType.UNENCRYPTED;
+ }
+
+ /**
+ * A wrapper for {@link #getPrivKeyBytes()} that returns null if the private key bytes are missing or would have
+ * to be derived (for the HD key case).
+ */
+ @Override
+ public byte[] getSecretBytes() {
+ if (hasPrivKey()) {
+ return getPrivKeyBytes();
+ } else {
+ return null;
+ }
+ }
+
+ /** An alias for {@link #getEncryptedPrivateKey()} */
+ @Override
+ public EncryptedData getEncryptedData() {
+ return getEncryptedPrivateKey();
+ }
+
+ /**
+ * Returns the the encrypted private key bytes and initialisation vector for this ECKey, or null if the ECKey
+ * is not encrypted.
+ */
+ public EncryptedData getEncryptedPrivateKey() {
+ return encryptedPrivateKey;
+ }
+
+ /**
+ * Returns the KeyCrypter that was used to encrypt to encrypt this ECKey. You need this to decrypt the ECKey.
+ */
+ public KeyCrypter getKeyCrypter() {
+ return keyCrypter;
+ }
+
+ public static class MissingPrivateKeyException extends RuntimeException {
+ }
+
+ public static class KeyIsEncryptedException extends MissingPrivateKeyException {
+ }
+
+ public static class InvalidPasswordException extends RuntimeException {
+ }
+
public static ECKey createKeyPbkdf2HmacSha512(String password) {
return createKeyPbkdf2HmacSha512(password, new byte[0], 1024);
}
@@ -637,24 +903,13 @@ public class ECKey {
byte[] key_m = new byte[hash.length-32];
System.arraycopy(hash, 32, key_m, 0, hash.length-32);
- byte[] ciphertext = encryptAesCbcPkcs7(message, iv, key_e);
+ AESKeyCrypter aesKeyCrypter = new AESKeyCrypter();
+ byte[] ciphertext = aesKeyCrypter.encrypt(message, iv, new KeyParameter(key_e)).getEncryptedBytes();
byte[] encrypted = concat(magic, ephemeral.getPubKey(), ciphertext);
byte[] result = hmac256(key_m, encrypted);
return Base64.getEncoder().encode(concat(encrypted, result));
}
- private byte[] encryptAesCbcPkcs7(byte[] message, byte[] iv, byte[] key_e) {
- try {
- Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
- SecretKeySpec secretKeySpec = new SecretKeySpec(key_e, "AES");
- AlgorithmParameterSpec paramSpec = new IvParameterSpec(iv);
- cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, paramSpec);
- return cipher.doFinal(message);
- } catch(Exception e) {
- throw new RuntimeException(e);
- }
- }
-
public byte[] decryptEcies(byte[] message, byte[] magic) {
byte[] decoded = Base64.getDecoder().decode(message);
if(decoded.length < 85) {
@@ -691,19 +946,8 @@ public class ECKey {
throw new InvalidPasswordException();
}
- return decryptAesCbcPkcs7(ciphertext, iv, key_e);
- }
-
- private byte[] decryptAesCbcPkcs7(byte[] ciphertext, byte[] iv, byte[] key_e) {
- try {
- Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
- SecretKeySpec secretKeySpec = new SecretKeySpec(key_e, "AES");
- AlgorithmParameterSpec paramSpec = new IvParameterSpec(iv);
- cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, paramSpec);
- return cipher.doFinal(ciphertext);
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
+ AESKeyCrypter aesKeyCrypter = new AESKeyCrypter();
+ return aesKeyCrypter.decrypt(new EncryptedData(iv, ciphertext), new KeyParameter(key_e));
}
private byte[] sha512(byte[] input) {
@@ -753,10 +997,4 @@ public class ECKey {
public String toString() {
return pub.toString();
}
-
- public static class MissingPrivateKeyException extends RuntimeException {
- }
-
- public static class InvalidPasswordException extends RuntimeException {
- }
}
diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/EncryptableItem.java b/src/main/java/com/sparrowwallet/drongo/crypto/EncryptableItem.java
new file mode 100644
index 0000000..3c9df08
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/crypto/EncryptableItem.java
@@ -0,0 +1,23 @@
+package com.sparrowwallet.drongo.crypto;
+
+/**
+ * Provides a uniform way to access something that can be optionally encrypted with a
+ * {@link KeyCrypter}, yielding an {@link EncryptedData}, and
+ * which can have a creation time associated with it.
+ */
+public interface EncryptableItem {
+ /** Returns whether the item is encrypted or not. If it is, then {@link #getSecretBytes()} will return null. */
+ boolean isEncrypted();
+
+ /** Returns the raw bytes of the item, if not encrypted, or null if encrypted or the secret is missing. */
+ byte[] getSecretBytes();
+
+ /** Returns the initialization vector and encrypted secret bytes, or null if not encrypted. */
+ EncryptedData getEncryptedData();
+
+ /** Returns an enum constant describing what algorithm was used to encrypt the key or UNENCRYPTED. */
+ EncryptionType getEncryptionType();
+
+ /** Returns the time in seconds since the UNIX epoch at which this encryptable item was first created/derived. */
+ long getCreationTimeSeconds();
+}
diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/EncryptedData.java b/src/main/java/com/sparrowwallet/drongo/crypto/EncryptedData.java
new file mode 100644
index 0000000..a5ac612
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/crypto/EncryptedData.java
@@ -0,0 +1,48 @@
+package com.sparrowwallet.drongo.crypto;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * An instance of EncryptedData is a holder for an initialization vector and encrypted bytes. It is typically
+ * used to hold encrypted private key bytes.
+ *
+ * The initialisation vector is random data that is used to initialise the AES block cipher when the
+ * private key bytes were encrypted. You need these for decryption.
+ */
+public final class EncryptedData {
+ private final byte[] initialisationVector;
+ private final byte[] encryptedBytes;
+
+ public EncryptedData(byte[] initialisationVector, byte[] encryptedBytes) {
+ this.initialisationVector = Arrays.copyOf(initialisationVector, initialisationVector.length);
+ this.encryptedBytes = Arrays.copyOf(encryptedBytes, encryptedBytes.length);
+ }
+
+ public byte[] getInitialisationVector() {
+ return initialisationVector;
+ }
+
+ public byte[] getEncryptedBytes() {
+ return encryptedBytes;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ EncryptedData other = (EncryptedData) o;
+ return Arrays.equals(encryptedBytes, other.encryptedBytes) && Arrays.equals(initialisationVector, other.initialisationVector);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(Arrays.hashCode(encryptedBytes), Arrays.hashCode(initialisationVector));
+ }
+
+ @Override
+ public String toString() {
+ return "EncryptedData [initialisationVector=" + Arrays.toString(initialisationVector)
+ + ", encryptedPrivateKey=" + Arrays.toString(encryptedBytes) + "]";
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/EncryptionType.java b/src/main/java/com/sparrowwallet/drongo/crypto/EncryptionType.java
new file mode 100644
index 0000000..18b36fc
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/crypto/EncryptionType.java
@@ -0,0 +1,5 @@
+package com.sparrowwallet.drongo.crypto;
+
+public enum EncryptionType {
+ UNENCRYPTED, ENCRYPTED_SCRYPT_AES, ENCRYPTED_ECIES;
+}
diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/KeyCrypter.java b/src/main/java/com/sparrowwallet/drongo/crypto/KeyCrypter.java
new file mode 100644
index 0000000..e390fbf
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/crypto/KeyCrypter.java
@@ -0,0 +1,50 @@
+package com.sparrowwallet.drongo.crypto;
+
+import org.bouncycastle.crypto.params.KeyParameter;
+
+import java.io.Serializable;
+
+/**
+ * A KeyCrypter can be used to encrypt and decrypt a message. The sequence of events to encrypt and then decrypt
+ * a message are as follows:
+ *
+ * (1) Ask the user for a password. deriveKey() is then called to create an KeyParameter. This contains the AES
+ * key that will be used for encryption.
+ * (2) Encrypt the message using encrypt(), providing the message bytes and the KeyParameter from (1). This returns
+ * an EncryptedData which contains the encryptedPrivateKey bytes and an initialisation vector.
+ * (3) To decrypt an EncryptedData, repeat step (1) to get a KeyParameter, then call decrypt().
+ *
+ * There can be different algorithms used for encryption/ decryption so the getUnderstoodEncryptionType is used
+ * to determine whether any given KeyCrypter can understand the type of encrypted data you have.
+ */
+public interface KeyCrypter {
+
+ /**
+ * Return the EncryptionType enum value which denotes the type of encryption/ decryption that this KeyCrypter
+ * can understand.
+ */
+ EncryptionType getUnderstoodEncryptionType();
+
+ /**
+ * Create a KeyParameter (which typically contains an AES key)
+ * @param password
+ * @return KeyParameter The KeyParameter which typically contains the AES key to use for encrypting and decrypting
+ * @throws KeyCrypterException
+ */
+ KeyParameter deriveKey(CharSequence password) throws KeyCrypterException;
+
+ /**
+ * Decrypt the provided encrypted bytes, converting them into unencrypted bytes.
+ *
+ * @throws KeyCrypterException if decryption was unsuccessful.
+ */
+ byte[] decrypt(EncryptedData encryptedBytesToDecode, KeyParameter aesKey) throws KeyCrypterException;
+
+ /**
+ * Encrypt the supplied bytes, converting them into ciphertext.
+ *
+ * @return encryptedPrivateKey An encryptedPrivateKey containing the encrypted bytes and an initialisation vector.
+ * @throws KeyCrypterException if encryption was unsuccessful
+ */
+ EncryptedData encrypt(byte[] plainBytes, byte[] initializationVector, KeyParameter aesKey) throws KeyCrypterException;
+}
diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/KeyCrypterException.java b/src/main/java/com/sparrowwallet/drongo/crypto/KeyCrypterException.java
new file mode 100644
index 0000000..d2b69b9
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/crypto/KeyCrypterException.java
@@ -0,0 +1,48 @@
+package com.sparrowwallet.drongo.crypto;
+
+/**
+ * Exception to provide the following:
+ *
+ * - Provision of encryption / decryption exception
+ *
+ * This base exception acts as a general failure mode not attributable to a specific cause (other than
+ * that reported in the exception message). Since this is in English, it may not be worth reporting directly
+ * to the user other than as part of a "general failure to parse" response.
+ */
+public class KeyCrypterException extends RuntimeException {
+ public KeyCrypterException(String s) {
+ super(s);
+ }
+
+ public KeyCrypterException(String s, Throwable throwable) {
+ super(s, throwable);
+ }
+
+ /**
+ * This exception is thrown when a private key or seed is decrypted, it doesn't match its public key any
+ * more. This likely means the wrong decryption key has been used.
+ */
+ public static class PublicPrivateMismatch extends KeyCrypterException {
+ public PublicPrivateMismatch(String message) {
+ super(message);
+ }
+
+ public PublicPrivateMismatch(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+ }
+
+ /**
+ * This exception is thrown when a private key or seed is decrypted, the decrypted message is damaged
+ * (e.g. the padding is damaged). This likely means the wrong decryption key has been used.
+ */
+ public static class InvalidCipherText extends KeyCrypterException {
+ public InvalidCipherText(String message) {
+ super(message);
+ }
+
+ public InvalidCipherText(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/ScryptKeyCrypter.java b/src/main/java/com/sparrowwallet/drongo/crypto/ScryptKeyCrypter.java
new file mode 100644
index 0000000..42b2ddf
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/crypto/ScryptKeyCrypter.java
@@ -0,0 +1,271 @@
+package com.sparrowwallet.drongo.crypto;
+
+import org.bouncycastle.crypto.BufferedBlockCipher;
+import org.bouncycastle.crypto.InvalidCipherTextException;
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.generators.SCrypt;
+import org.bouncycastle.crypto.modes.CBCBlockCipher;
+import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.bouncycastle.crypto.params.ParametersWithIV;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * This class encrypts and decrypts byte arrays and strings using scrypt as the
+ * key derivation function and AES for the encryption.
+ *
+ * You can use this class to:
+ *
+ * 1) Using a user password, create an AES key that can encrypt and decrypt your private keys.
+ * To convert the password to the AES key, scrypt is used. This is an algorithm resistant
+ * to brute force attacks. You can use the ScryptParameters to tune how difficult you
+ * want this to be generation to be.
+ *
+ * 2) Using the AES Key generated above, you then can encrypt and decrypt any bytes using
+ * the AES symmetric cipher. Eight bytes of salt is used to prevent dictionary attacks.
+ */
+public class ScryptKeyCrypter implements KeyCrypter {
+ private static final Logger log = LoggerFactory.getLogger(ScryptKeyCrypter.class);
+
+ /**
+ * Key length in bytes.
+ */
+ public static final int KEY_LENGTH = 32; // = 256 bits.
+
+ /**
+ * The size of an AES block in bytes.
+ * This is also the length of the initialisation vector.
+ */
+ public static final int BLOCK_LENGTH = 16; // = 128 bits.
+
+ /**
+ * The length of the salt used.
+ */
+ public static final int SALT_LENGTH = 8;
+
+ private static final SecureRandom secureRandom = new SecureRandom();
+
+ /** Returns SALT_LENGTH (8) bytes of random data */
+ public static byte[] randomSalt() {
+ byte[] salt = new byte[SALT_LENGTH];
+ secureRandom.nextBytes(salt);
+ return salt;
+ }
+
+ // Scrypt parameters.
+ private final ScryptParameters scryptParameters;
+
+ /**
+ * Encryption/Decryption using default parameters and a random salt.
+ */
+ public ScryptKeyCrypter() {
+ this.scryptParameters = new ScryptParameters(randomSalt());
+ }
+
+ /**
+ * Encryption/Decryption using custom number of iterations parameters and a random salt.
+ * As of August 2016, a useful value for mobile devices is 4096 (derivation takes about 1 second).
+ *
+ * @param iterations
+ * number of scrypt iterations
+ */
+ public ScryptKeyCrypter(int iterations) {
+ this.scryptParameters = new ScryptParameters(randomSalt(), iterations);
+ }
+
+ /**
+ * Encryption/ Decryption using specified Scrypt parameters.
+ *
+ * @param scryptParameters ScryptParameters to use
+ * @throws NullPointerException if the scryptParameters or any of its N, R or P is null.
+ */
+ public ScryptKeyCrypter(ScryptParameters scryptParameters) {
+ this.scryptParameters = scryptParameters;
+ if (scryptParameters.getSalt() == null || scryptParameters.getSalt() == null || scryptParameters.getSalt().length == 0) {
+ log.warn("You are using a ScryptParameters with no salt. Your encryption may be vulnerable to a dictionary attack.");
+ }
+ }
+
+ /**
+ * Generate AES key.
+ *
+ * This is a very slow operation compared to encrypt/ decrypt so it is normally worth caching the result.
+ *
+ * @param password The password to use in key generation
+ * @return The KeyParameter containing the created AES key
+ * @throws KeyCrypterException
+ */
+ @Override
+ public KeyParameter deriveKey(CharSequence password) throws KeyCrypterException {
+ byte[] passwordBytes = null;
+ try {
+ passwordBytes = convertToByteArray(password);
+ byte[] salt = new byte[0];
+ if (scryptParameters.getSalt() != null) {
+ salt = scryptParameters.getSalt();
+ } else {
+ log.warn("You are using a ScryptParameters with no salt. Your encryption may be vulnerable to a dictionary attack.");
+ }
+
+ byte[] keyBytes = SCrypt.generate(passwordBytes, salt, (int) scryptParameters.getN(), scryptParameters.getR(), scryptParameters.getP(), KEY_LENGTH);
+ return new KeyParameter(keyBytes);
+ } catch (Exception e) {
+ throw new KeyCrypterException("Could not generate key from password and salt.", e);
+ } finally {
+ // Zero the password bytes.
+ if(passwordBytes != null) {
+ java.util.Arrays.fill(passwordBytes, (byte) 0);
+ }
+ }
+ }
+
+ /**
+ * Password based encryption using AES - CBC 256 bits.
+ */
+ @Override
+ public EncryptedData encrypt(byte[] plainBytes, byte[] initializationVector, KeyParameter aesKey) throws KeyCrypterException {
+ if(plainBytes == null || aesKey == null) {
+ throw new KeyCrypterException("Data and key to encrypt cannot be null");
+ }
+
+ try {
+ // Generate iv - each encryption call has a different iv.
+ byte[] iv = initializationVector;
+ if(iv == null) {
+ iv = new byte[BLOCK_LENGTH];
+ secureRandom.nextBytes(iv);
+ }
+
+ ParametersWithIV keyWithIv = new ParametersWithIV(aesKey, iv);
+
+ // Encrypt using AES.
+ BufferedBlockCipher cipher = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESEngine()));
+ cipher.init(true, keyWithIv);
+ byte[] encryptedBytes = new byte[cipher.getOutputSize(plainBytes.length)];
+ final int length1 = cipher.processBytes(plainBytes, 0, plainBytes.length, encryptedBytes, 0);
+ final int length2 = cipher.doFinal(encryptedBytes, length1);
+
+ return new EncryptedData(iv, Arrays.copyOf(encryptedBytes, length1 + length2));
+ } catch (Exception e) {
+ throw new KeyCrypterException("Could not encrypt bytes.", e);
+ }
+ }
+
+ /**
+ * Decrypt bytes previously encrypted with this class.
+ *
+ * @param dataToDecrypt The data to decrypt
+ * @param aesKey The AES key to use for decryption
+ * @return The decrypted bytes
+ * @throws KeyCrypterException if bytes could not be decrypted
+ */
+ @Override
+ public byte[] decrypt(EncryptedData dataToDecrypt, KeyParameter aesKey) throws KeyCrypterException {
+ if(dataToDecrypt == null || aesKey == null) {
+ throw new KeyCrypterException("Data and key to decrypt cannot be null");
+ }
+
+ try {
+ ParametersWithIV keyWithIv = new ParametersWithIV(new KeyParameter(aesKey.getKey()), dataToDecrypt.getInitialisationVector());
+
+ // Decrypt the message.
+ BufferedBlockCipher cipher = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESEngine()));
+ cipher.init(false, keyWithIv);
+
+ byte[] cipherBytes = dataToDecrypt.getEncryptedBytes();
+ byte[] decryptedBytes = new byte[cipher.getOutputSize(cipherBytes.length)];
+ final int length1 = cipher.processBytes(cipherBytes, 0, cipherBytes.length, decryptedBytes, 0);
+ final int length2 = cipher.doFinal(decryptedBytes, length1);
+
+ return Arrays.copyOf(decryptedBytes, length1 + length2);
+ } catch (InvalidCipherTextException e) {
+ throw new KeyCrypterException.InvalidCipherText("Could not decrypt bytes", e);
+ } catch (RuntimeException e) {
+ throw new KeyCrypterException("Could not decrypt bytes", e);
+ }
+ }
+
+ /**
+ * Convert a CharSequence (which are UTF16) into a byte array.
+ *
+ * Note: a String.getBytes() is not used to avoid creating a String of the password in the JVM.
+ */
+ private static byte[] convertToByteArray(CharSequence charSequence) {
+ byte[] byteArray = new byte[charSequence.length() << 1];
+ for(int i = 0; i < charSequence.length(); i++) {
+ int bytePosition = i << 1;
+ byteArray[bytePosition] = (byte) ((charSequence.charAt(i)&0xFF00)>>8);
+ byteArray[bytePosition + 1] = (byte) (charSequence.charAt(i)&0x00FF);
+ }
+ return byteArray;
+ }
+
+ public ScryptParameters getScryptParameters() {
+ return scryptParameters;
+ }
+
+ /**
+ * Return the EncryptionType enum value which denotes the type of encryption/ decryption that this KeyCrypter
+ * can understand.
+ */
+ @Override
+ public EncryptionType getUnderstoodEncryptionType() {
+ return EncryptionType.ENCRYPTED_SCRYPT_AES;
+ }
+
+ @Override
+ public String toString() {
+ return "AES-" + KEY_LENGTH * 8 + "-CBC, Scrypt (" + scryptParametersString() + ")";
+ }
+
+ private String scryptParametersString() {
+ return "N=" + scryptParameters.getN() + ", r=" + scryptParameters.getR() + ", p=" + scryptParameters.getP();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(scryptParameters);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ return Objects.equals(scryptParameters, ((ScryptKeyCrypter)o).scryptParameters);
+ }
+
+ public static class ScryptParameters {
+ private final byte[] salt;
+ private long n = 16384L;
+
+ public ScryptParameters(byte[] salt) {
+ this.salt = salt;
+ }
+
+ public ScryptParameters(byte[] salt, long iterations) {
+ this.salt = salt;
+ this.n = iterations;
+ }
+
+ byte[] getSalt() {
+ return salt;
+ }
+
+ long getN() {
+ return n;
+ }
+
+ int getR() {
+ return 8;
+ }
+
+ int getP() {
+ return 1;
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Bip39Calculator.java b/src/main/java/com/sparrowwallet/drongo/wallet/Bip39Calculator.java
deleted file mode 100644
index 8ed616d..0000000
--- a/src/main/java/com/sparrowwallet/drongo/wallet/Bip39Calculator.java
+++ /dev/null
@@ -1,108 +0,0 @@
-package com.sparrowwallet.drongo.wallet;
-
-import com.sparrowwallet.drongo.Utils;
-import com.sparrowwallet.drongo.protocol.Sha256Hash;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
-import java.text.Normalizer;
-import java.util.*;
-
-public class Bip39Calculator {
- private static Map wordlistIndex;
-
- public byte[] getSeed(List mnemonicWords, String passphrase) {
- loadWordlistIndex();
-
- int concatLength = mnemonicWords.size() * 11;
- StringBuilder concat = new StringBuilder();
- for(String mnemonicWord : mnemonicWords) {
- Integer index = wordlistIndex.get(mnemonicWord);
- if (index == null) {
- throw new IllegalArgumentException("Provided mnemonic word \"" + mnemonicWord + "\" is not in the BIP39 english word list");
- }
-
- String binaryIndex = addLeadingZeros(Integer.toBinaryString(index), 11);
- concat.append(binaryIndex, 0, 11);
- }
-
- int checksumLength = concatLength / 33;
- int entropyLength = concatLength - checksumLength;
- byte[] entropy = byteArrayFromBinaryString(concat.substring(0, entropyLength));
- String providedChecksum = concat.substring(entropyLength);
-
- byte[] sha256 = Sha256Hash.hash(entropy);
- String calculatedChecksum = addLeadingZeros(Integer.toBinaryString(Byte.toUnsignedInt(sha256[0])), 8).substring(0, checksumLength);
-
- if(!providedChecksum.equals(calculatedChecksum)) {
- throw new IllegalArgumentException("Provided mnemonic words do not represent a valid BIP39 seed: checksum failed");
- }
-
- String saltStr = "mnemonic";
- if(passphrase != null) {
- saltStr += Normalizer.normalize(passphrase, Normalizer.Form.NFKD);
- }
- byte[] salt = saltStr.getBytes(StandardCharsets.UTF_8);
-
- String mnemonic = String.join(" ", mnemonicWords);
- mnemonic = Normalizer.normalize(mnemonic, Normalizer.Form.NFKD);
-
- return Utils.getPbkdf2HmacSha512Hash(mnemonic.getBytes(StandardCharsets.UTF_8), salt, 2048);
- }
-
- public String addLeadingZeros(String s, int length) {
- if (s.length() >= length) return s;
- else return String.format("%0" + (length-s.length()) + "d%s", 0, s);
- }
-
- private byte[] byteArrayFromBinaryString(String binaryString) {
- int splitSize = 8;
-
- if(binaryString.length() < splitSize) {
- binaryString = addLeadingZeros(binaryString, 8);
- }
-
- if(binaryString.length() % splitSize == 0){
- int index = 0;
- int position = 0;
-
- byte[] resultByteArray = new byte[binaryString.length()/splitSize];
- StringBuilder text = new StringBuilder(binaryString);
-
- while (index < text.length()) {
- String binaryStringChunk = text.substring(index, Math.min(index + splitSize, text.length()));
- int byteAsInt = Integer.parseInt(binaryStringChunk, 2);
- resultByteArray[position] = (byte)byteAsInt;
- index += splitSize;
- position ++;
- }
- return resultByteArray;
- }
- else {
- throw new IllegalArgumentException("Cannot convert binary string to byte[], because of the input length '" + binaryString + "' % 8 != 0");
- }
- }
-
- private synchronized void loadWordlistIndex() {
- if(wordlistIndex == null) {
- wordlistIndex = new HashMap<>();
-
- try{
- BufferedReader reader = new BufferedReader(new InputStreamReader(this.getClass().getResourceAsStream("/wordlist/bip39-english.txt"), StandardCharsets.UTF_8));
- String line;
- for(int i = 0; (line = reader.readLine()) != null; i++) {
- wordlistIndex.put(line.trim(), i);
- }
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
- }
-
- public List getWordList() {
- loadWordlistIndex();
- return Collections.unmodifiableList(new ArrayList<>(wordlistIndex.keySet()));
- }
-}
diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCode.java b/src/main/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCode.java
new file mode 100644
index 0000000..d0433e6
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCode.java
@@ -0,0 +1,230 @@
+package com.sparrowwallet.drongo.wallet;
+
+import com.sparrowwallet.drongo.Utils;
+import com.sparrowwallet.drongo.protocol.Sha256Hash;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.text.Normalizer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class Bip39MnemonicCode {
+ private static final Logger log = LoggerFactory.getLogger(Bip39MnemonicCode.class);
+
+ private final ArrayList wordList;
+
+ private static final String BIP39_ENGLISH_RESOURCE_NAME = "/wordlist/bip39-english.txt";
+ private static final String BIP39_ENGLISH_SHA256 = "ad90bf3beb7b0eb7e5acd74727dc0da96e0a280a258354e7293fb7e211ac03db";
+
+ /** UNIX time for when the BIP39 standard was finalised. This can be used as a default seed birthday. */
+ public static long BIP39_STANDARDISATION_TIME_SECS = 1381276800;
+
+ private static final int PBKDF2_ROUNDS = 2048;
+
+ public static Bip39MnemonicCode INSTANCE;
+
+ static {
+ try {
+ INSTANCE = new Bip39MnemonicCode();
+ } catch (RuntimeException e) {
+ log.error("Failed to load word list", e);
+ }
+ }
+
+ /** Initialise from the included word list. Won't work on Android. */
+ public Bip39MnemonicCode() {
+ this(openDefaultWords(), BIP39_ENGLISH_SHA256);
+ }
+
+ private static InputStream openDefaultWords() {
+ InputStream stream = Bip39MnemonicCode.class.getResourceAsStream(BIP39_ENGLISH_RESOURCE_NAME);
+ if(stream == null) {
+ throw new RuntimeException(new FileNotFoundException(BIP39_ENGLISH_RESOURCE_NAME));
+ }
+
+ return stream;
+ }
+
+ /**
+ * Creates an MnemonicCode object, initializing with words read from the supplied input stream. If a wordListDigest
+ * is supplied the digest of the words will be checked.
+ */
+ public Bip39MnemonicCode(InputStream wordstream, String wordListDigest) throws IllegalArgumentException {
+ try {
+ BufferedReader br = new BufferedReader(new InputStreamReader(wordstream, StandardCharsets.UTF_8));
+ this.wordList = new ArrayList<>(2048);
+ MessageDigest md = Sha256Hash.newDigest();
+ String word;
+ while ((word = br.readLine()) != null) {
+ md.update(word.getBytes());
+ this.wordList.add(word);
+ }
+ br.close();
+
+ if (this.wordList.size() != 2048) {
+ throw new IllegalArgumentException("Input stream did not contain 2048 words");
+ }
+
+ // If a wordListDigest is supplied check to make sure it matches.
+ if (wordListDigest != null) {
+ byte[] digest = md.digest();
+ String hexdigest = Utils.bytesToHex(digest);
+ if (!hexdigest.equals(wordListDigest)) {
+ throw new IllegalArgumentException("Wordlist digest mismatch");
+ }
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Error loading word list", e);
+ }
+ }
+
+ /**
+ * Gets the word list this code uses.
+ */
+ public List getWordList() {
+ return Collections.unmodifiableList(wordList);
+ }
+
+ /**
+ * Convert mnemonic word list to seed.
+ */
+ public static byte[] toSeed(List words, String passphrase) {
+ if(passphrase == null) {
+ throw new IllegalArgumentException("A null passphrase is not allowed.");
+ }
+
+ // To create binary seed from mnemonic, we use PBKDF2 function
+ // with mnemonic sentence (in UTF-8) used as a password and
+ // string "mnemonic" + passphrase (again in UTF-8) used as a
+ // salt. Iteration count is set to 2048 and HMAC-SHA512 is
+ // used as a pseudo-random function. Desired length of the
+ // derived key is 512 bits (= 64 bytes).
+ //
+ String mnemonic = String.join(" ", words);
+ String salt = "mnemonic" + Normalizer.normalize(passphrase, Normalizer.Form.NFKD);
+
+ return Utils.getPbkdf2HmacSha512Hash(mnemonic.getBytes(StandardCharsets.UTF_8), salt.getBytes(StandardCharsets.UTF_8), PBKDF2_ROUNDS);
+ }
+
+ /**
+ * Convert mnemonic word list to original entropy value.
+ */
+ public byte[] toEntropy(List words) throws MnemonicException.MnemonicLengthException, MnemonicException.MnemonicWordException, MnemonicException.MnemonicChecksumException {
+ if (words.size() % 3 > 0) {
+ throw new MnemonicException.MnemonicLengthException("Word list size must be multiple of three words.");
+ }
+
+ if (words.size() == 0) {
+ throw new MnemonicException.MnemonicLengthException("Word list is empty.");
+ }
+
+ // Look up all the words in the list and construct the
+ // concatenation of the original entropy and the checksum.
+ //
+ int concatLenBits = words.size() * 11;
+ boolean[] concatBits = new boolean[concatLenBits];
+ int wordindex = 0;
+ for (String word : words) {
+ // Find the words index in the wordlist.
+ int ndx = Collections.binarySearch(this.wordList, word);
+ if (ndx < 0) {
+ throw new MnemonicException.MnemonicWordException(word);
+ }
+ // Set the next 11 bits to the value of the index.
+ for (int ii = 0; ii < 11; ++ii) {
+ concatBits[(wordindex * 11) + ii] = (ndx & (1 << (10 - ii))) != 0;
+ }
+ ++wordindex;
+ }
+
+ int checksumLengthBits = concatLenBits / 33;
+ int entropyLengthBits = concatLenBits - checksumLengthBits;
+
+ // Extract original entropy as bytes.
+ byte[] entropy = new byte[entropyLengthBits / 8];
+ for (int ii = 0; ii < entropy.length; ++ii) {
+ for (int jj = 0; jj < 8; ++jj) {
+ if (concatBits[(ii * 8) + jj]) {
+ entropy[ii] |= 1 << (7 - jj);
+ }
+ }
+ }
+
+ // Take the digest of the entropy.
+ byte[] hash = Sha256Hash.hash(entropy);
+ boolean[] hashBits = bytesToBits(hash);
+
+ // Check all the checksum bits.
+ for (int i = 0; i < checksumLengthBits; ++i) {
+ if (concatBits[entropyLengthBits + i] != hashBits[i]) {
+ throw new MnemonicException.MnemonicChecksumException();
+ }
+ }
+ return entropy;
+ }
+
+ /**
+ * Convert entropy data to mnemonic word list.
+ */
+ public List toMnemonic(byte[] entropy) throws MnemonicException.MnemonicLengthException {
+ if (entropy.length % 4 > 0) {
+ throw new MnemonicException.MnemonicLengthException("Entropy length not multiple of 32 bits.");
+ }
+ if (entropy.length == 0) {
+ throw new MnemonicException.MnemonicLengthException("Entropy is empty.");
+ }
+ // We take initial entropy of ENT bits and compute its
+ // checksum by taking first ENT / 32 bits of its SHA256 hash.
+
+ byte[] hash = Sha256Hash.hash(entropy);
+ boolean[] hashBits = bytesToBits(hash);
+
+ boolean[] entropyBits = bytesToBits(entropy);
+ int checksumLengthBits = entropyBits.length / 32;
+
+ // We append these bits to the end of the initial entropy.
+ boolean[] concatBits = new boolean[entropyBits.length + checksumLengthBits];
+ System.arraycopy(entropyBits, 0, concatBits, 0, entropyBits.length);
+ System.arraycopy(hashBits, 0, concatBits, entropyBits.length, checksumLengthBits);
+
+ // Next we take these concatenated bits and split them into
+ // groups of 11 bits. Each group encodes number from 0-2047
+ // which is a position in a wordlist. We convert numbers into
+ // words and use joined words as mnemonic sentence.
+
+ ArrayList words = new ArrayList<>();
+ int nwords = concatBits.length / 11;
+ for (int i = 0; i < nwords; ++i) {
+ int index = 0;
+ for (int j = 0; j < 11; ++j) {
+ index <<= 1;
+ if (concatBits[(i * 11) + j]) {
+ index |= 0x1;
+ }
+ }
+ words.add(this.wordList.get(index));
+ }
+
+ return words;
+ }
+
+ /**
+ * Check to see if a mnemonic word list is valid.
+ */
+ public void check(List words) throws MnemonicException {
+ toEntropy(words);
+ }
+
+ private static boolean[] bytesToBits(byte[] data) {
+ boolean[] bits = new boolean[data.length * 8];
+ for (int i = 0; i < data.length; ++i)
+ for (int j = 0; j < 8; ++j)
+ bits[(i * 8) + j] = (data[i] & (1 << (7 - j))) != 0;
+ return bits;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/DeterministicSeed.java b/src/main/java/com/sparrowwallet/drongo/wallet/DeterministicSeed.java
new file mode 100644
index 0000000..1422612
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/wallet/DeterministicSeed.java
@@ -0,0 +1,164 @@
+package com.sparrowwallet.drongo.wallet;
+
+import com.sparrowwallet.drongo.Utils;
+import com.sparrowwallet.drongo.crypto.EncryptableItem;
+import com.sparrowwallet.drongo.crypto.EncryptedData;
+import com.sparrowwallet.drongo.crypto.EncryptionType;
+import com.sparrowwallet.drongo.crypto.KeyCrypter;
+import org.bouncycastle.crypto.params.KeyParameter;
+
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+public class DeterministicSeed implements EncryptableItem {
+ public static final int DEFAULT_SEED_ENTROPY_BITS = 128;
+ public static final int MAX_SEED_ENTROPY_BITS = 512;
+
+ private final byte[] seed;
+ private final EncryptedData encryptedSeed;
+ private long creationTimeSeconds;
+
+ public DeterministicSeed(byte[] seed, long creationTimeSeconds) {
+ this.seed = seed;
+ this.encryptedSeed = null;
+ this.creationTimeSeconds = creationTimeSeconds;
+ }
+
+ public DeterministicSeed(EncryptedData encryptedSeed, long creationTimeSeconds) {
+ this.seed = null;
+ this.encryptedSeed = encryptedSeed;
+ this.creationTimeSeconds = creationTimeSeconds;
+ }
+
+ /**
+ * Constructs a seed from a BIP 39 mnemonic code. See {@link Bip39MnemonicCode} for more
+ * details on this scheme.
+ * @param random Entropy source
+ * @param bits number of bits, must be divisible by 32
+ * @param passphrase A user supplied passphrase, or an empty string if there is no passphrase
+ */
+ public DeterministicSeed(SecureRandom random, int bits, String passphrase) {
+ this(getEntropy(random, bits), passphrase, System.currentTimeMillis());
+ }
+
+ /**
+ * Constructs a seed from a BIP 39 mnemonic code. See {@link Bip39MnemonicCode} for more
+ * details on this scheme.
+ * @param entropy entropy bits, length must be divisible by 32
+ * @param passphrase A user supplied passphrase, or an empty string if there is no passphrase
+ * @param creationTimeSeconds When the seed was originally created, UNIX time.
+ */
+ public DeterministicSeed(byte[] entropy, String passphrase, long creationTimeSeconds) {
+ if(entropy.length % 4 != 0) {
+ throw new IllegalArgumentException("Entropy size in bits not divisible by 32");
+ }
+
+ if(entropy.length * 8 < DEFAULT_SEED_ENTROPY_BITS) {
+ throw new IllegalArgumentException("Entropy size too small");
+ }
+
+ if(passphrase == null) {
+ passphrase = "";
+ }
+
+ List mnemonicCode;
+ try {
+ mnemonicCode = Bip39MnemonicCode.INSTANCE.toMnemonic(entropy);
+ } catch (MnemonicException.MnemonicLengthException e) {
+ // cannot happen
+ throw new RuntimeException(e);
+ }
+ this.seed = Bip39MnemonicCode.toSeed(mnemonicCode, passphrase);
+ this.encryptedSeed = null;
+ this.creationTimeSeconds = creationTimeSeconds;
+ }
+
+ private static byte[] getEntropy(SecureRandom random, int bits) {
+ if(bits > MAX_SEED_ENTROPY_BITS) {
+ throw new IllegalArgumentException("Requested entropy size too large");
+ }
+
+ byte[] seed = new byte[bits / 8];
+ random.nextBytes(seed);
+ return seed;
+ }
+
+ @Override
+ public boolean isEncrypted() {
+ return encryptedSeed != null;
+ }
+
+ public String toString() {
+ if(isEncrypted()) {
+ return encryptedSeed.toString();
+ }
+
+ return Utils.bytesToHex(seed);
+ }
+
+ /** Returns the seed as hex or null if encrypted. */
+ public String toHexString() {
+ return seed != null ? Utils.bytesToHex(seed) : null;
+ }
+
+ @Override
+ public byte[] getSecretBytes() {
+ return getSeedBytes();
+ }
+
+ public byte[] getSeedBytes() {
+ return seed;
+ }
+
+ @Override
+ public EncryptedData getEncryptedData() {
+ return encryptedSeed;
+ }
+
+ @Override
+ public EncryptionType getEncryptionType() {
+ return EncryptionType.ENCRYPTED_SCRYPT_AES;
+ }
+
+ @Override
+ public long getCreationTimeSeconds() {
+ return creationTimeSeconds;
+ }
+
+ public void setCreationTimeSeconds(long creationTimeSeconds) {
+ this.creationTimeSeconds = creationTimeSeconds;
+ }
+
+ public DeterministicSeed encrypt(KeyCrypter keyCrypter, KeyParameter aesKey) {
+ if(encryptedSeed != null) {
+ throw new IllegalArgumentException("Trying to encrypt seed twice");
+ }
+
+ EncryptedData encryptedSeed = keyCrypter.encrypt(seed, null, aesKey);
+ return new DeterministicSeed(encryptedSeed, creationTimeSeconds);
+ }
+
+ public DeterministicSeed decrypt(KeyCrypter crypter, String passphrase, KeyParameter aesKey) {
+ if(!isEncrypted()) {
+ throw new IllegalStateException("Cannot decrypt unencrypted seed");
+ }
+ byte[] seed = crypter.decrypt(encryptedSeed, aesKey);
+ return new DeterministicSeed(seed, passphrase, creationTimeSeconds);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ DeterministicSeed other = (DeterministicSeed) o;
+ return creationTimeSeconds == other.creationTimeSeconds
+ && (isEncrypted() ? encryptedSeed.equals(other.encryptedSeed) : Arrays.equals(seed, other.seed));
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(creationTimeSeconds, isEncrypted() ? encryptedSeed : seed);
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/MnemonicException.java b/src/main/java/com/sparrowwallet/drongo/wallet/MnemonicException.java
new file mode 100644
index 0000000..02a1cb6
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/drongo/wallet/MnemonicException.java
@@ -0,0 +1,42 @@
+package com.sparrowwallet.drongo.wallet;
+
+public class MnemonicException extends Exception {
+ public MnemonicException() {
+ super();
+ }
+
+ public MnemonicException(String msg) {
+ super(msg);
+ }
+
+ /**
+ * Thrown when an argument to MnemonicCode is the wrong length.
+ */
+ public static class MnemonicLengthException extends MnemonicException {
+ public MnemonicLengthException(String msg) {
+ super(msg);
+ }
+ }
+
+ /**
+ * Thrown when a list of MnemonicCode words fails the checksum check.
+ */
+ public static class MnemonicChecksumException extends MnemonicException {
+ public MnemonicChecksumException() {
+ super();
+ }
+ }
+
+ /**
+ * Thrown when a word is encountered which is not in the MnemonicCode's word list.
+ */
+ public static class MnemonicWordException extends MnemonicException {
+ /** Contains the word that was not found in the word list. */
+ public final String badWord;
+
+ public MnemonicWordException(String badWord) {
+ super();
+ this.badWord = badWord;
+ }
+ }
+}
diff --git a/src/test/java/com/sparrowwallet/drongo/wallet/Bip39CalculatorTest.java b/src/test/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCodeTest.java
similarity index 66%
rename from src/test/java/com/sparrowwallet/drongo/wallet/Bip39CalculatorTest.java
rename to src/test/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCodeTest.java
index 8a02db8..3a28321 100644
--- a/src/test/java/com/sparrowwallet/drongo/wallet/Bip39CalculatorTest.java
+++ b/src/test/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCodeTest.java
@@ -7,89 +7,89 @@ import org.junit.Test;
import java.util.Arrays;
import java.util.List;
-public class Bip39CalculatorTest {
+public class Bip39MnemonicCodeTest {
@Test
- public void bip39TwelveWordsTest() {
+ public void bip39TwelveWordsTest() throws MnemonicException {
String words = "absent essay fox snake vast pumpkin height crouch silent bulb excuse razor";
List wordlist = Arrays.asList(words.split(" "));
- Bip39Calculator bip39Calculator = new Bip39Calculator();
- byte[] seed = bip39Calculator.getSeed(wordlist, "");
+ Bip39MnemonicCode.INSTANCE.check(wordlist);
+ byte[] seed = Bip39MnemonicCode.toSeed(wordlist, "");
Assert.assertEquals("727ecfcf0bce9d8ec0ef066f7aeb845c271bdd4ee06a37398cebd40dc810140bb620b6c10a8ad671afdceaf37aa55d92d6478f747e8b92430dd938ab5be961dd", Utils.bytesToHex(seed));
}
- @Test(expected = IllegalArgumentException.class)
- public void bip39TwelveWordsInvalidTest() {
+ @Test(expected = MnemonicException.MnemonicChecksumException.class)
+ public void bip39TwelveWordsInvalidTest() throws MnemonicException {
String words = "absent absent absent absent absent absent absent absent absent absent absent absent";
List wordlist = Arrays.asList(words.split(" "));
- Bip39Calculator bip39Calculator = new Bip39Calculator();
- byte[] seed = bip39Calculator.getSeed(wordlist, "");
+ Bip39MnemonicCode.INSTANCE.check(wordlist);
+ byte[] seed = Bip39MnemonicCode.toSeed(wordlist, "");
}
@Test
- public void bip39TwelveWordsPassphraseTest() {
+ public void bip39TwelveWordsPassphraseTest() throws MnemonicException {
String words = "arch easily near social civil image seminar monkey engine party promote turtle";
List wordlist = Arrays.asList(words.split(" "));
- Bip39Calculator bip39Calculator = new Bip39Calculator();
- byte[] seed = bip39Calculator.getSeed(wordlist, "anotherpass867");
+ Bip39MnemonicCode.INSTANCE.check(wordlist);
+ byte[] seed = Bip39MnemonicCode.toSeed(wordlist, "anotherpass867");
Assert.assertEquals("ca50764cda44a2cf52aef3c677bebf26011f9dc2b9fddfed2a8a5a9ecb8542956990a16e6873b7724044e83708d9d3a662b765e8800e6e79b289f51c2bcad756", Utils.bytesToHex(seed));
}
@Test
- public void bip39FifteenWordsTest() {
+ public void bip39FifteenWordsTest() throws MnemonicException {
String words = "open grunt omit snap behave inch engine hamster hope increase exotic segment news choose roast";
List wordlist = Arrays.asList(words.split(" "));
- Bip39Calculator bip39Calculator = new Bip39Calculator();
- byte[] seed = bip39Calculator.getSeed(wordlist, "");
+ Bip39MnemonicCode.INSTANCE.check(wordlist);
+ byte[] seed = Bip39MnemonicCode.toSeed(wordlist, "");
Assert.assertEquals("2174deae5fd315253dc065db7ef97f46957eb68a12505adccfb7f8aca5b63788c587e73430848f85417d9a7d95e6396d2eb3af73c9fb507ebcb9268a5ad47885", Utils.bytesToHex(seed));
}
@Test
- public void bip39EighteenWordsTest() {
+ public void bip39EighteenWordsTest() throws MnemonicException {
String words = "mandate lend daring actual health dilemma throw muffin garden pony inherit volume slim visual police supreme bless crush";
List wordlist = Arrays.asList(words.split(" "));
- Bip39Calculator bip39Calculator = new Bip39Calculator();
- byte[] seed = bip39Calculator.getSeed(wordlist, "");
+ Bip39MnemonicCode.INSTANCE.check(wordlist);
+ byte[] seed = Bip39MnemonicCode.toSeed(wordlist, "");
Assert.assertEquals("04bd65f582e288bbf595213048b06e1552017776d20ca290ac06d840e197bcaaccd4a85a45a41219be4183dd2e521e7a7a2d6aea3069f04e503ef6d9c8dfa651", Utils.bytesToHex(seed));
}
@Test
- public void bip39TwentyOneWordsTest() {
+ public void bip39TwentyOneWordsTest() throws MnemonicException {
String words = "mirror milk file hope drill conduct empty mutual physical easily sell patient green final release excuse name asset update advance resource";
List wordlist = Arrays.asList(words.split(" "));
- Bip39Calculator bip39Calculator = new Bip39Calculator();
- byte[] seed = bip39Calculator.getSeed(wordlist, "");
+ Bip39MnemonicCode.INSTANCE.check(wordlist);
+ byte[] seed = Bip39MnemonicCode.toSeed(wordlist, "");
Assert.assertEquals("f3a88a437153333f9759f323dfe7910e6a649c34da5800e6c978d77baad54b67b06eab17c0107243f3e8b395a2de98c910e9528127539efda2eea5ae50e94019", Utils.bytesToHex(seed));
}
@Test
- public void bip39TwentyFourWordsTest() {
+ public void bip39TwentyFourWordsTest() throws MnemonicException {
String words = "earth easily dwarf dance forum muscle brick often huge base long steel silk frost quiz liquid echo adapt annual expand slim rookie venture oval";
List wordlist = Arrays.asList(words.split(" "));
- Bip39Calculator bip39Calculator = new Bip39Calculator();
- byte[] seed = bip39Calculator.getSeed(wordlist, "");
+ Bip39MnemonicCode.INSTANCE.check(wordlist);
+ byte[] seed = Bip39MnemonicCode.toSeed(wordlist, "");
Assert.assertEquals("60f825219a1fcfa479de28435e9bf2aa5734e212982daee582ca0427ad6141c65be9863c3ce0f18e2b173083ea49dcf47d07148734a5f748ac60d470cee6a2bc", Utils.bytesToHex(seed));
}
@Test
- public void bip39TwentyFourWordsPassphraseTest() {
+ public void bip39TwentyFourWordsPassphraseTest() throws MnemonicException {
String words = "earth easily dwarf dance forum muscle brick often huge base long steel silk frost quiz liquid echo adapt annual expand slim rookie venture oval";
List wordlist = Arrays.asList(words.split(" "));
- Bip39Calculator bip39Calculator = new Bip39Calculator();
- byte[] seed = bip39Calculator.getSeed(wordlist, "thispass");
+ Bip39MnemonicCode.INSTANCE.check(wordlist);
+ byte[] seed = Bip39MnemonicCode.toSeed(wordlist, "thispass");
Assert.assertEquals("a652d123f421f56257391af26063e900619678b552dafd3850e699f6da0667269bbcaebb0509557481db29607caac0294b3cd337d740174cfa05f552fe9e0272", Utils.bytesToHex(seed));
}