From a750a14edd0e8c2071d732a49bdc95f778348f85 Mon Sep 17 00:00:00 2001 From: Andreas Schildbach Date: Sun, 6 Jul 2014 17:25:36 +0200 Subject: [PATCH] Implement BIP38 password encrypted private keys, decryption only. Contains all the test vectors from the spec minus one that is incompatible to Java. --- .../java/com/google/bitcoin/core/ECKey.java | 36 +++- .../core/VersionedChecksummedBytes.java | 12 +- .../bitcoin/crypto/BIP38PrivateKey.java | 180 ++++++++++++++++++ .../bitcoin/crypto/BIP38PrivateKeyTest.java | 157 +++++++++++++++ 4 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 core/src/main/java/com/google/bitcoin/crypto/BIP38PrivateKey.java create mode 100644 core/src/test/java/com/google/bitcoin/crypto/BIP38PrivateKeyTest.java diff --git a/core/src/main/java/com/google/bitcoin/core/ECKey.java b/core/src/main/java/com/google/bitcoin/core/ECKey.java index 586ee0da..74784ad1 100644 --- a/core/src/main/java/com/google/bitcoin/core/ECKey.java +++ b/core/src/main/java/com/google/bitcoin/core/ECKey.java @@ -89,6 +89,9 @@ import static com.google.common.base.Preconditions.checkState; public class ECKey implements EncryptableItem, Serializable { private static final Logger log = LoggerFactory.getLogger(ECKey.class); + /** The parameters of the secp256k1 curve that Bitcoin uses. */ + public static final X9ECParameters CURVE_PARAMS = SECNamedCurves.getByName("secp256k1"); + /** The parameters of the secp256k1 curve that Bitcoin uses. */ public static final ECDomainParameters CURVE; @@ -103,9 +106,9 @@ public class ECKey implements EncryptableItem, Serializable { static { // All clients must agree on the curve to use by agreement. Bitcoin uses secp256k1. - X9ECParameters params = SECNamedCurves.getByName("secp256k1"); - CURVE = new ECDomainParameters(params.getCurve(), params.getG(), params.getN(), params.getH()); - HALF_CURVE_ORDER = params.getN().shiftRight(1); + CURVE = new ECDomainParameters(CURVE_PARAMS.getCurve(), CURVE_PARAMS.getG(), CURVE_PARAMS.getN(), + CURVE_PARAMS.getH()); + HALF_CURVE_ORDER = CURVE_PARAMS.getN().shiftRight(1); secureRandom = new SecureRandom(); } @@ -178,21 +181,38 @@ public class ECKey implements EncryptableItem, Serializable { } /** - * Creates an ECKey given the private key only. The public key is calculated from it (this is slow). Note that - * the resulting public key is compressed. + * Creates an ECKey given the private key only. The public key is calculated from it (this is slow). The resulting + * public key is compressed. */ public static ECKey fromPrivate(BigInteger privKey) { - return new ECKey(privKey, compressPoint(CURVE.getG().multiply(privKey))); + return fromPrivate(privKey, true); } /** - * Creates an ECKey given the private key only. The public key is calculated from it (this is slow). The resulting + * Creates an ECKey given the private key only. The public key is calculated from it (this is slow), either + * compressed or not. + */ + public static ECKey fromPrivate(BigInteger privKey, boolean compressed) { + ECPoint point = CURVE.getG().multiply(privKey); + return new ECKey(privKey, compressed ? compressPoint(point) : decompressPoint(point)); + } + + /** + * Creates an ECKey given the private key only. The public key is calculated from it (this is slow). The resulting * public key is compressed. */ public static ECKey fromPrivate(byte[] privKeyBytes) { return fromPrivate(new BigInteger(1, privKeyBytes)); } + /** + * Creates an ECKey given the private key only. The public key is calculated from it (this is slow), either + * compressed or not. + */ + public static ECKey fromPrivate(byte[] privKeyBytes, boolean compressed) { + return fromPrivate(new BigInteger(1, privKeyBytes), compressed); + } + /** * Creates an ECKey that simply trusts the caller to ensure that point is really the result of multiplying the * generator point by the private key. This is used to speed things up when you know you have the right values @@ -350,7 +370,7 @@ public class ECKey implements EncryptableItem, Serializable { DERSequenceGenerator seq = new DERSequenceGenerator(baos); seq.addObject(new ASN1Integer(1)); // version seq.addObject(new DEROctetString(privKeyBytes)); - seq.addObject(new DERTaggedObject(0, SECNamedCurves.getByName("secp256k1").toASN1Primitive())); + seq.addObject(new DERTaggedObject(0, CURVE_PARAMS.toASN1Primitive())); seq.addObject(new DERTaggedObject(1, new DERBitString(getPubKey()))); seq.close(); return baos.toByteArray(); diff --git a/core/src/main/java/com/google/bitcoin/core/VersionedChecksummedBytes.java b/core/src/main/java/com/google/bitcoin/core/VersionedChecksummedBytes.java index a0215ae2..d9aa0aea 100644 --- a/core/src/main/java/com/google/bitcoin/core/VersionedChecksummedBytes.java +++ b/core/src/main/java/com/google/bitcoin/core/VersionedChecksummedBytes.java @@ -16,10 +16,14 @@ package com.google.bitcoin.core; +import static com.google.common.base.Preconditions.checkArgument; + import java.io.Serializable; import java.util.Arrays; -import static com.google.common.base.Preconditions.checkArgument; +import org.spongycastle.util.Integers; + +import com.google.common.base.Objects; /** *

In Bitcoin the following format is often used to represent some type of key:

@@ -59,10 +63,9 @@ public class VersionedChecksummedBytes implements Serializable { return Base58.encode(addressBytes); } - // TODO: shouldn't hashCode be also based on the version? @Override public int hashCode() { - return Arrays.hashCode(bytes); + return Objects.hashCode(version, Arrays.hashCode(bytes)); } @Override @@ -70,7 +73,8 @@ public class VersionedChecksummedBytes implements Serializable { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; VersionedChecksummedBytes other = (VersionedChecksummedBytes) o; - return Arrays.equals(bytes, other.bytes); + return this.version == other.version + && Arrays.equals(this.bytes, other.bytes); } /** diff --git a/core/src/main/java/com/google/bitcoin/crypto/BIP38PrivateKey.java b/core/src/main/java/com/google/bitcoin/crypto/BIP38PrivateKey.java new file mode 100644 index 00000000..6527f262 --- /dev/null +++ b/core/src/main/java/com/google/bitcoin/crypto/BIP38PrivateKey.java @@ -0,0 +1,180 @@ +/* + * Copyright 2014 Andreas Schildbach + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.bitcoin.crypto; + +import static com.google.common.base.Preconditions.checkState; + +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.text.Normalizer; +import java.util.Arrays; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +import com.google.bitcoin.core.AddressFormatException; +import com.google.bitcoin.core.ECKey; +import com.google.bitcoin.core.NetworkParameters; +import com.google.bitcoin.core.Sha256Hash; +import com.google.bitcoin.core.VersionedChecksummedBytes; +import com.google.common.base.Charsets; +import com.google.common.base.Objects; +import com.google.common.primitives.Bytes; +import com.lambdaworks.crypto.SCrypt; + +/** + * Implementation of BIP 38 + * passphrase-protected private keys. Currently, only decryption is supported. + */ +public class BIP38PrivateKey extends VersionedChecksummedBytes { + + public final NetworkParameters params; + public final boolean ecMultiply; + public final boolean compressed; + public final boolean hasLotAndSequence; + public final byte[] addressHash; + public final byte[] content; + + public static final class BadPassphraseException extends Exception { + } + + public BIP38PrivateKey(NetworkParameters params, String encoded) throws AddressFormatException { + super(encoded); + this.params = params; + if (version != 0x01) + throw new AddressFormatException("Mismatched version number: " + version); + if (bytes.length != 38) + throw new AddressFormatException("Wrong number of bytes, excluding version byte: " + bytes.length); + hasLotAndSequence = (bytes[1] & 0x04) != 0; // bit 2 + compressed = (bytes[1] & 0x20) != 0; // bit 5 + if ((bytes[1] & 0x01) != 0) // bit 0 + throw new AddressFormatException("Bit 0x40 reserved for future use."); + if ((bytes[1] & 0x02) != 0) // bit 1 + throw new AddressFormatException("Bit 0x80 reserved for future use."); + if ((bytes[1] & 0x08) != 0) // bit 3 + throw new AddressFormatException("Bit 0x08 reserved for future use."); + if ((bytes[1] & 0x10) != 0) // bit 4 + throw new AddressFormatException("Bit 0x10 reserved for future use."); + final int byte0 = bytes[0] & 0xff; + if (byte0 == 0x42) { + // Non-EC-multiplied key + if ((bytes[1] & 0xc0) != 0xc0) // bits 6+7 + throw new AddressFormatException("Bits 0x40 and 0x80 must be set for non-EC-multiplied keys."); + ecMultiply = false; + if (hasLotAndSequence) + throw new AddressFormatException("Non-EC-multiplied keys cannot have lot/sequence."); + } else if (byte0 == 0x43) { + // EC-multiplied key + if ((bytes[1] & 0xc0) != 0x00) // bits 6+7 + throw new AddressFormatException("Bits 0x40 and 0x80 must be cleared for EC-multiplied keys."); + ecMultiply = true; + } else { + throw new AddressFormatException("Second byte must by 0x42 or 0x43."); + } + addressHash = Arrays.copyOfRange(bytes, 2, 6); + content = Arrays.copyOfRange(bytes, 6, 38); + } + + public ECKey decrypt(String passphrase) throws AddressFormatException, BadPassphraseException { + String normalizedPassphrase = Normalizer.normalize(passphrase, Normalizer.Form.NFC); + ECKey key = ecMultiply ? decryptEC(normalizedPassphrase) : decryptNoEC(normalizedPassphrase); + Sha256Hash hash = Sha256Hash.createDouble(key.toAddress(params).toString().getBytes(Charsets.US_ASCII)); + byte[] actualAddressHash = Arrays.copyOfRange(hash.getBytes(), 0, 4); + if (!Arrays.equals(actualAddressHash, addressHash)) + throw new BadPassphraseException(); + return key; + } + + private ECKey decryptNoEC(String normalizedPassphrase) { + try { + byte[] derived = SCrypt.scrypt(normalizedPassphrase.getBytes(Charsets.UTF_8), addressHash, 16384, 8, 8, 64); + byte[] key = Arrays.copyOfRange(derived, 32, 64); + SecretKeySpec keyspec = new SecretKeySpec(key, "AES"); + Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, keyspec); + byte[] decrypted = cipher.doFinal(content, 0, 32); + for (int i = 0; i < 32; i++) + decrypted[i] ^= derived[i]; + return ECKey.fromPrivate(decrypted, compressed); + } catch (GeneralSecurityException x) { + throw new RuntimeException(x); + } + } + + private ECKey decryptEC(String normalizedPassphrase) { + try { + byte[] ownerEntropy = Arrays.copyOfRange(content, 0, 8); + byte[] ownerSalt = hasLotAndSequence ? Arrays.copyOfRange(ownerEntropy, 0, 4) : ownerEntropy; + + byte[] passFactorBytes = SCrypt.scrypt(normalizedPassphrase.getBytes(Charsets.UTF_8), ownerSalt, 16384, 8, 8, 32); + if (hasLotAndSequence) { + byte[] hashBytes = Bytes.concat(passFactorBytes, ownerEntropy); + checkState(hashBytes.length == 40); + passFactorBytes = Sha256Hash.createDouble(hashBytes).getBytes(); + } + BigInteger passFactor = new BigInteger(1, passFactorBytes); + ECKey k = ECKey.fromPrivate(passFactor, true); + + byte[] salt = Bytes.concat(addressHash, ownerEntropy); + checkState(salt.length == 12); + byte[] derived = SCrypt.scrypt(k.getPubKey(), salt, 1024, 1, 1, 64); + byte[] aeskey = Arrays.copyOfRange(derived, 32, 64); + + SecretKeySpec keyspec = new SecretKeySpec(aeskey, "AES"); + Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, keyspec); + + byte[] encrypted2 = Arrays.copyOfRange(content, 16, 32); + byte[] decrypted2 = cipher.doFinal(encrypted2); + checkState(decrypted2.length == 16); + for (int i = 0; i < 16; i++) + decrypted2[i] ^= derived[i + 16]; + + byte[] encrypted1 = Bytes.concat(Arrays.copyOfRange(content, 8, 16), Arrays.copyOfRange(decrypted2, 0, 8)); + byte[] decrypted1 = cipher.doFinal(encrypted1); + checkState(decrypted1.length == 16); + for (int i = 0; i < 16; i++) + decrypted1[i] ^= derived[i]; + + byte[] seed = Bytes.concat(decrypted1, Arrays.copyOfRange(decrypted2, 8, 16)); + checkState(seed.length == 24); + BigInteger seedFactor = new BigInteger(1, Sha256Hash.createDouble(seed).getBytes()); + checkState(passFactor.signum() >= 0); + checkState(seedFactor.signum() >= 0); + BigInteger priv = passFactor.multiply(seedFactor).mod(ECKey.CURVE_PARAMS.getN()); + + return ECKey.fromPrivate(priv, compressed); + } catch (GeneralSecurityException x) { + throw new RuntimeException(x); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BIP38PrivateKey other = (BIP38PrivateKey) o; + + return super.equals(other) + && Objects.equal(this.params, other.params); + } + + @Override + public int hashCode() { + return Objects.hashCode(super.hashCode(), params); + } +} diff --git a/core/src/test/java/com/google/bitcoin/crypto/BIP38PrivateKeyTest.java b/core/src/test/java/com/google/bitcoin/crypto/BIP38PrivateKeyTest.java new file mode 100644 index 00000000..88c8b612 --- /dev/null +++ b/core/src/test/java/com/google/bitcoin/crypto/BIP38PrivateKeyTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2014 Andreas Schildbach + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.bitcoin.crypto; + +import static org.junit.Assert.assertEquals; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +import org.junit.Ignore; +import org.junit.Test; + +import com.google.bitcoin.core.ECKey; +import com.google.bitcoin.crypto.BIP38PrivateKey.BadPassphraseException; +import com.google.bitcoin.params.MainNetParams; +import com.google.bitcoin.params.TestNet3Params; + +public class BIP38PrivateKeyTest { + + private static final MainNetParams MAINNET = MainNetParams.get(); + private static final TestNet3Params TESTNET = TestNet3Params.get(); + + @Test + public void bip38testvector_noCompression_noEcMultiply_test1() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MAINNET, + "6PRVWUbkzzsbcVac2qwfssoUJAN1Xhrg6bNk8J7Nzm5H7kxEbn2Nh2ZoGg"); + ECKey key = encryptedKey.decrypt("TestingOneTwoThree"); + assertEquals("5KN7MzqK5wt2TP1fQCYyHBtDrXdJuXbUzm4A9rKAteGu3Qi5CVR", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + public void bip38testvector_noCompression_noEcMultiply_test2() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MAINNET, + "6PRNFFkZc2NZ6dJqFfhRoFNMR9Lnyj7dYGrzdgXXVMXcxoKTePPX1dWByq"); + ECKey key = encryptedKey.decrypt("Satoshi"); + assertEquals("5HtasZ6ofTHP6HCwTqTkLDuLQisYPah7aUnSKfC7h4hMUVw2gi5", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + @Ignore("Test disabled because you cannot pass \\u0000 in Strings.") + public void bip38testvector_noCompression_noEcMultiply_test3() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MAINNET, + "6PRW5o9FLp4gJDDVqJQKJFTpMvdsSGJxMYHtHaQBF3ooa8mwD69bapcDQn"); + ECKey key = encryptedKey.decrypt("\u03d2\u0301\u0000\u00010400\u0001f4a9"); + assertEquals("5Jajm8eQ22H3pGWLEVCXyvND8dQZhiQhoLJNKjYXk9roUFTMSZ4", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + public void bip38testvector_compression_noEcMultiply_test1() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MainNetParams.get(), + "6PYNKZ1EAgYgmQfmNVamxyXVWHzK5s6DGhwP4J5o44cvXdoY7sRzhtpUeo"); + ECKey key = encryptedKey.decrypt("TestingOneTwoThree"); + assertEquals("L44B5gGEpqEDRS9vVPz7QT35jcBG2r3CZwSwQ4fCewXAhAhqGVpP", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + public void bip38testvector_compression_noEcMultiply_test2() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MainNetParams.get(), + "6PYLtMnXvfG3oJde97zRyLYFZCYizPU5T3LwgdYJz1fRhh16bU7u6PPmY7"); + ECKey key = encryptedKey.decrypt("Satoshi"); + assertEquals("KwYgW8gcxj1JWJXhPSu4Fqwzfhp5Yfi42mdYmMa4XqK7NJxXUSK7", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + public void bip38testvector_ecMultiply_noCompression_noLotAndSequence_test1() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MainNetParams.get(), + "6PfQu77ygVyJLZjfvMLyhLMQbYnu5uguoJJ4kMCLqWwPEdfpwANVS76gTX"); + ECKey key = encryptedKey.decrypt("TestingOneTwoThree"); + assertEquals("5K4caxezwjGCGfnoPTZ8tMcJBLB7Jvyjv4xxeacadhq8nLisLR2", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + public void bip38testvector_ecMultiply_noCompression_noLotAndSequence_test2() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MainNetParams.get(), + "6PfLGnQs6VZnrNpmVKfjotbnQuaJK4KZoPFrAjx1JMJUa1Ft8gnf5WxfKd"); + ECKey key = encryptedKey.decrypt("Satoshi"); + assertEquals("5KJ51SgxWaAYR13zd9ReMhJpwrcX47xTJh2D3fGPG9CM8vkv5sH", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + public void bip38testvector_ecMultiply_noCompression_lotAndSequence_test1() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MainNetParams.get(), + "6PgNBNNzDkKdhkT6uJntUXwwzQV8Rr2tZcbkDcuC9DZRsS6AtHts4Ypo1j"); + ECKey key = encryptedKey.decrypt("MOLON LABE"); + assertEquals("5JLdxTtcTHcfYcmJsNVy1v2PMDx432JPoYcBTVVRHpPaxUrdtf8", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + public void bip38testvector_ecMultiply_noCompression_lotAndSequence_test2() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MainNetParams.get(), + "6PgGWtx25kUg8QWvwuJAgorN6k9FbE25rv5dMRwu5SKMnfpfVe5mar2ngH"); + ECKey key = encryptedKey.decrypt("ΜΟΛΩΝ ΛΑΒΕ"); + assertEquals("5KMKKuUmAkiNbA3DazMQiLfDq47qs8MAEThm4yL8R2PhV1ov33D", key.getPrivateKeyEncoded(MAINNET) + .toString()); + } + + @Test + public void bitcoinpaperwallet_testnet() throws Exception { + // values taken from bitcoinpaperwallet.com + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(TESTNET, + "6PRPhQhmtw6dQu6jD8E1KS4VphwJxBS9Eh9C8FQELcrwN3vPvskv9NKvuL"); + ECKey key = encryptedKey.decrypt("password"); + assertEquals("93MLfjbY6ugAsLeQfFY6zodDa8izgm1XAwA9cpMbUTwLkDitopg", key.getPrivateKeyEncoded(TESTNET) + .toString()); + } + + @Test + public void bitaddress_testnet() throws Exception { + // values taken from bitaddress.org + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(TESTNET, + "6PfMmVHn153N3x83Yiy4Nf76dHUkXufe2Adr9Fw5bewrunGNeaw2QCpifb"); + ECKey key = encryptedKey.decrypt("password"); + assertEquals("91tCpdaGr4Khv7UAuUxa6aMqeN5GcPVJxzLtNsnZHTCndxkRcz2", key.getPrivateKeyEncoded(TESTNET) + .toString()); + } + + @Test(expected = BadPassphraseException.class) + public void badPassphrase() throws Exception { + BIP38PrivateKey encryptedKey = new BIP38PrivateKey(MAINNET, + "6PRVWUbkzzsbcVac2qwfssoUJAN1Xhrg6bNk8J7Nzm5H7kxEbn2Nh2ZoGg"); + encryptedKey.decrypt("BAD"); + } + + @Test + public void testJavaSerialization() throws Exception { + BIP38PrivateKey key = new BIP38PrivateKey(TESTNET, "6PfMmVHn153N3x83Yiy4Nf76dHUkXufe2Adr9Fw5bewrunGNeaw2QCpifb"); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + new ObjectOutputStream(os).writeObject(key); + BIP38PrivateKey keyCopy = (BIP38PrivateKey) new ObjectInputStream(new ByteArrayInputStream(os.toByteArray())) + .readObject(); + assertEquals(key, keyCopy); + } +}