From dc569979e175ab092e7b77a809fb14bc53e8c643 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Sun, 10 May 2020 13:28:16 +0200 Subject: [PATCH] encryptableitem and bip39 from bitcoinj --- .../drongo/crypto/AESKeyCrypter.java | 67 ++++ .../sparrowwallet/drongo/crypto/ECKey.java | 318 +++++++++++++++--- .../drongo/crypto/EncryptableItem.java | 23 ++ .../drongo/crypto/EncryptedData.java | 48 +++ .../drongo/crypto/EncryptionType.java | 5 + .../drongo/crypto/KeyCrypter.java | 50 +++ .../drongo/crypto/KeyCrypterException.java | 48 +++ .../drongo/crypto/ScryptKeyCrypter.java | 271 +++++++++++++++ .../drongo/wallet/Bip39Calculator.java | 108 ------ .../drongo/wallet/Bip39MnemonicCode.java | 230 +++++++++++++ .../drongo/wallet/DeterministicSeed.java | 164 +++++++++ .../drongo/wallet/MnemonicException.java | 42 +++ ...orTest.java => Bip39MnemonicCodeTest.java} | 52 +-- 13 files changed, 1252 insertions(+), 174 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/drongo/crypto/AESKeyCrypter.java create mode 100644 src/main/java/com/sparrowwallet/drongo/crypto/EncryptableItem.java create mode 100644 src/main/java/com/sparrowwallet/drongo/crypto/EncryptedData.java create mode 100644 src/main/java/com/sparrowwallet/drongo/crypto/EncryptionType.java create mode 100644 src/main/java/com/sparrowwallet/drongo/crypto/KeyCrypter.java create mode 100644 src/main/java/com/sparrowwallet/drongo/crypto/KeyCrypterException.java create mode 100644 src/main/java/com/sparrowwallet/drongo/crypto/ScryptKeyCrypter.java delete mode 100644 src/main/java/com/sparrowwallet/drongo/wallet/Bip39Calculator.java create mode 100644 src/main/java/com/sparrowwallet/drongo/wallet/Bip39MnemonicCode.java create mode 100644 src/main/java/com/sparrowwallet/drongo/wallet/DeterministicSeed.java create mode 100644 src/main/java/com/sparrowwallet/drongo/wallet/MnemonicException.java rename src/test/java/com/sparrowwallet/drongo/wallet/{Bip39CalculatorTest.java => Bip39MnemonicCodeTest.java} (66%) diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/AESKeyCrypter.java b/src/main/java/com/sparrowwallet/drongo/crypto/AESKeyCrypter.java new file mode 100644 index 0000000..01d10b9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/crypto/AESKeyCrypter.java @@ -0,0 +1,67 @@ +package com.sparrowwallet.drongo.crypto; + +import com.sparrowwallet.drongo.Utils; +import org.bouncycastle.crypto.params.KeyParameter; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.spec.AlgorithmParameterSpec; + +public class AESKeyCrypter implements KeyCrypter { + + @Override + public EncryptionType getUnderstoodEncryptionType() { + return null; + } + + @Override + public KeyParameter deriveKey(CharSequence password) throws KeyCrypterException { + return createKeyPbkdf2HmacSha512(password.toString()); + } + + public static KeyParameter createKeyPbkdf2HmacSha512(String password) { + return createKeyPbkdf2HmacSha512(password, new byte[0], 1024); + } + + public static KeyParameter createKeyPbkdf2HmacSha512(String password, byte[] salt, int iterationCount) { + byte[] secret = Utils.getPbkdf2HmacSha512Hash(password.getBytes(StandardCharsets.UTF_8), salt, iterationCount); + return new KeyParameter(secret); + } + + @Override + public byte[] decrypt(EncryptedData encryptedBytesToDecode, KeyParameter aesKey) throws KeyCrypterException { + return decryptAesCbcPkcs7(encryptedBytesToDecode.getEncryptedBytes(), encryptedBytesToDecode.getInitialisationVector(), aesKey.getKey()); + } + + 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 KeyCrypterException("Error decrypting", e); + } + } + + @Override + public EncryptedData encrypt(byte[] plainBytes, byte[] initializationVector, KeyParameter aesKey) throws KeyCrypterException { + byte[] encryptedData = encryptAesCbcPkcs7(plainBytes, initializationVector, aesKey.getKey()); + return new EncryptedData(initializationVector, encryptedData); + } + + 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 KeyCrypterException("Could not encrypt", e); + } + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java index 7f7fad9..d3aa94e 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java @@ -10,7 +10,6 @@ import org.bouncycastle.crypto.digests.SHA256Digest; import org.bouncycastle.crypto.digests.SHA512Digest; import org.bouncycastle.crypto.ec.CustomNamedCurves; import org.bouncycastle.crypto.generators.ECKeyPairGenerator; -import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator; import org.bouncycastle.crypto.macs.HMac; import org.bouncycastle.crypto.params.*; import org.bouncycastle.crypto.signers.ECDSASigner; @@ -24,17 +23,14 @@ import org.bouncycastle.util.encoders.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.crypto.Cipher; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.*; -import java.security.spec.AlgorithmParameterSpec; import java.util.Arrays; import java.util.Base64; +import java.util.Comparator; import java.util.Objects; /** @@ -65,9 +61,21 @@ import java.util.Objects; * this class so round-tripping preserves state. Unless you're working with old software or doing unusual things, you * can usually ignore the compressed/uncompressed distinction.

*/ -public class ECKey { +public class ECKey implements EncryptableItem { private static final Logger log = LoggerFactory.getLogger(ECKey.class); + /** Sorts oldest keys first, newest last. */ + public static final Comparator AGE_COMPARATOR = new Comparator() { + + @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)); }