Add API functions to initialize Haveno account (#216)

Co-authored-by: woodser@protonmail.com
This commit is contained in:
duriancrepe 2022-02-09 01:39:57 -08:00 committed by GitHub
parent dc4692d97a
commit e3b9a9962b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 2755 additions and 1660 deletions

View file

@ -114,6 +114,7 @@ public class Config {
public static final String BTC_MIN_TX_FEE = "btcMinTxFee";
public static final String BTC_FEES_TS = "bitcoinFeesTs";
public static final String BYPASS_MEMPOOL_VALIDATION = "bypassMempoolValidation";
public static final String PASSWORD_REQUIRED = "passwordRequired";
// Default values for certain options
public static final int UNSPECIFIED_PORT = -1;
@ -190,6 +191,7 @@ public class Config {
public final boolean preventPeriodicShutdownAtSeedNode;
public final boolean republishMailboxEntries;
public final boolean bypassMempoolValidation;
public final boolean passwordRequired;
// Properties derived from options but not exposed as options themselves
public final File torDir;
@ -579,6 +581,13 @@ public class Config {
.ofType(boolean.class)
.defaultsTo(false);
ArgumentAcceptingOptionSpec<Boolean> passwordRequiredOpt =
parser.accepts(PASSWORD_REQUIRED,
"Requires a password for creating a Haveno account")
.withRequiredArg()
.ofType(boolean.class)
.defaultsTo(false);
try {
CompositeOptionSet options = new CompositeOptionSet();
@ -686,6 +695,7 @@ public class Config {
this.preventPeriodicShutdownAtSeedNode = options.valueOf(preventPeriodicShutdownAtSeedNodeOpt);
this.republishMailboxEntries = options.valueOf(republishMailboxEntriesOpt);
this.bypassMempoolValidation = options.valueOf(bypassMempoolValidationOpt);
this.passwordRequired = options.valueOf(passwordRequiredOpt);
} catch (OptionException ex) {
throw new ConfigException("problem parsing option '%s': %s",
ex.options().get(0),

View file

@ -54,11 +54,13 @@ public class Encryption {
public static final String ASYM_KEY_ALGO = "RSA";
private static final String ASYM_CIPHER = "RSA/ECB/OAEPWithSHA-256AndMGF1PADDING";
private static final String SYM_KEY_ALGO = "AES";
public static final String SYM_KEY_ALGO = "AES";
private static final String SYM_CIPHER = "AES";
private static final String HMAC = "HmacSHA256";
public static final String HMAC_ERROR_MSG = "Hmac does not match.";
public static KeyPair generateKeyPair() {
long ts = System.currentTimeMillis();
try {
@ -101,11 +103,6 @@ public class Encryption {
return new SecretKeySpec(secretKeyBytes, 0, secretKeyBytes.length, SYM_KEY_ALGO);
}
public static byte[] getSecretKeyBytes(SecretKey secretKey) {
return secretKey.getEncoded();
}
///////////////////////////////////////////////////////////////////////////////////////////
// Hmac
///////////////////////////////////////////////////////////////////////////////////////////
@ -179,7 +176,7 @@ public class Encryption {
if (verifyHmac(Hex.decode(payloadAsHex), Hex.decode(hmacAsHex), secretKey)) {
return Hex.decode(payloadAsHex);
} else {
throw new CryptoException("Hmac does not match.");
throw new CryptoException(HMAC_ERROR_MSG);
}
}

View file

@ -0,0 +1,23 @@
/*
* This file is part of Haveno.
*
* Haveno is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Haveno is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Haveno. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.common.crypto;
public class IncorrectPasswordException extends Exception {
public IncorrectPasswordException(String message) {
super(message);
}
}

View file

@ -26,27 +26,93 @@ import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
@Getter
@EqualsAndHashCode
@Slf4j
@Singleton
public final class KeyRing {
private final KeyPair signatureKeyPair;
private final KeyPair encryptionKeyPair;
private final PubKeyRing pubKeyRing;
private final KeyStorage keyStorage;
private KeyPair signatureKeyPair;
private KeyPair encryptionKeyPair;
private PubKeyRing pubKeyRing;
/**
* Creates the KeyRing. Unlocks if not encrypted. Does not generate keys.
*
* @param keyStorage Persisted storage
*/
@Inject
public KeyRing(KeyStorage keyStorage) {
if (keyStorage.allKeyFilesExist()) {
signatureKeyPair = keyStorage.loadKeyPair(KeyStorage.KeyEntry.MSG_SIGNATURE);
encryptionKeyPair = keyStorage.loadKeyPair(KeyStorage.KeyEntry.MSG_ENCRYPTION);
} else {
// First time we create key pairs
signatureKeyPair = Sig.generateKeyPair();
encryptionKeyPair = Encryption.generateKeyPair();
keyStorage.saveKeyRing(this);
this(keyStorage, null, false);
}
/**
* Creates KeyRing with a password. Attempts to generate keys if they don't exist.
*
* @param keyStorage Persisted storage
* @param password The password to unlock the keys or to generate new keys, nullable.
* @param generateKeys Generate new keys with password if not created yet.
*/
public KeyRing(KeyStorage keyStorage, String password, boolean generateKeys) {
this.keyStorage = keyStorage;
try {
unlockKeys(password, generateKeys);
} catch(IncorrectPasswordException ex) {
// no action
}
}
public boolean isUnlocked() {
boolean isUnlocked = this.signatureKeyPair != null
&& this.encryptionKeyPair != null
&& this.pubKeyRing != null;
return isUnlocked;
}
/**
* Locks the keyring disabling access to the keys until unlock is called.
* If the keys are never persisted then the keys are lost and will be regenerated.
*/
public void lockKeys() {
signatureKeyPair = null;
encryptionKeyPair = null;
pubKeyRing = null;
}
/**
* Unlocks the keyring with a given password if required. If the keyring is already
* unlocked, do nothing.
*
* @param password Decrypts the or encrypts newly generated keys with the given password.
* @return Whether KeyRing is unlocked
*/
public boolean unlockKeys(@Nullable String password, boolean generateKeys) throws IncorrectPasswordException {
if (isUnlocked()) return true;
if (keyStorage.allKeyFilesExist()) {
signatureKeyPair = keyStorage.loadKeyPair(KeyStorage.KeyEntry.MSG_SIGNATURE, password);
encryptionKeyPair = keyStorage.loadKeyPair(KeyStorage.KeyEntry.MSG_ENCRYPTION, password);
if (signatureKeyPair != null && encryptionKeyPair != null) pubKeyRing = new PubKeyRing(signatureKeyPair.getPublic(), encryptionKeyPair.getPublic());
} else if (generateKeys) {
generateKeys(password);
}
return isUnlocked();
}
/**
* Generates a new set of keys if the current keyring is closed.
*
* @param password The password to unlock the keys or to generate new keys, nullable.
*/
public void generateKeys(String password) {
if (isUnlocked()) throw new Error("Current keyring must be closed to generate new keys");
signatureKeyPair = Sig.generateKeyPair();
encryptionKeyPair = Encryption.generateKeyPair();
pubKeyRing = new PubKeyRing(signatureKeyPair.getPublic(), encryptionKeyPair.getPublic());
keyStorage.saveKeyRing(this, password);
}
// Don't print keys for security reasons

View file

@ -20,11 +20,19 @@ package bisq.common.crypto;
import bisq.common.config.Config;
import bisq.common.file.FileUtil;
import org.bitcoinj.crypto.KeyCrypterScrypt;
import com.google.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.bouncycastle.crypto.params.KeyParameter;
import javax.crypto.BadPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
@ -39,6 +47,9 @@ import java.security.spec.KeySpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
@ -46,6 +57,7 @@ import java.io.IOException;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.Date;
import org.slf4j.Logger;
@ -58,6 +70,11 @@ import static bisq.common.util.Preconditions.checkDir;
@Singleton
public class KeyStorage {
private static final Logger log = LoggerFactory.getLogger(KeyStorage.class);
private static final int SALT_LENGTH = 20;
private static final byte[] ENCRYPTED_FORMAT_MAGIC = "HVNENC".getBytes(StandardCharsets.UTF_8);
private static final int ENCRYPTED_FORMAT_VERSION = 1;
private static final int ENCRYPTED_FORMAT_LENGTH = 4*2; // version,salt
public enum KeyEntry {
MSG_SIGNATURE("sig", Sig.KEY_ALGO),
@ -104,7 +121,7 @@ public class KeyStorage {
return new File(storageDir + "/" + keyEntry.getFileName() + ".key").exists();
}
public KeyPair loadKeyPair(KeyEntry keyEntry) {
public KeyPair loadKeyPair(KeyEntry keyEntry, String password) throws IncorrectPasswordException {
FileUtil.rollingBackup(storageDir, keyEntry.getFileName() + ".key", 20);
// long now = System.currentTimeMillis();
try {
@ -118,9 +135,53 @@ public class KeyStorage {
//noinspection ResultOfMethodCallIgnored
fis.read(encodedPrivateKey);
// Read magic bytes
byte[] magicBytes = Arrays.copyOfRange(encodedPrivateKey, 0, ENCRYPTED_FORMAT_MAGIC.length);
boolean isEncryptedPassword = Arrays.compare(magicBytes, ENCRYPTED_FORMAT_MAGIC) == 0;
if (isEncryptedPassword && password == null) {
throw new IncorrectPasswordException("Cannot load encrypted keys, user must open account with password " + filePrivateKey);
} else if (password != null && !isEncryptedPassword) {
log.warn("Password not needed for unencrypted key " + filePrivateKey);
}
// Decrypt using password
if (password != null) {
int position = ENCRYPTED_FORMAT_MAGIC.length;
// Read remaining header
ByteBuffer buf = ByteBuffer.wrap(encodedPrivateKey, position, ENCRYPTED_FORMAT_LENGTH);
position += ENCRYPTED_FORMAT_LENGTH;
int version = buf.getInt();
if (version != 1) throw new RuntimeException("Unable to parse encrypted keys");
int saltLength = buf.getInt();
// Read salt
byte[] salt = Arrays.copyOfRange(encodedPrivateKey, position, position + saltLength);
position += saltLength;
// Payload key derived from password
KeyCrypterScrypt crypter = ScryptUtil.getKeyCrypterScrypt(salt);
KeyParameter pwKey = ScryptUtil.deriveKeyWithScrypt(crypter, password);
SecretKey secretKey = new SecretKeySpec(pwKey.getKey(), Encryption.SYM_KEY_ALGO);
byte[] encryptedPayload = Arrays.copyOfRange(encodedPrivateKey, position, encodedPrivateKey.length);
// Decrypt key, handling exceptions caused by an incorrect password key
try {
encodedPrivateKey = Encryption.decryptPayloadWithHmac(encryptedPayload, secretKey);
} catch (CryptoException ce) {
// Most of the time (probably of slightly less than 255/256, around 99.61%) a bad password
// will result in BadPaddingException before HMAC check.
// See https://stackoverflow.com/questions/8049872/given-final-block-not-properly-padded
if (ce.getCause() instanceof BadPaddingException || ce.getMessage() == Encryption.HMAC_ERROR_MSG)
throw new IncorrectPasswordException("Incorrect password");
else
throw ce;
}
}
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(encodedPrivateKey);
privateKey = keyFactory.generatePrivate(privateKeySpec);
} catch (InvalidKeySpecException | IOException e) {
} catch (InvalidKeySpecException | IOException | CryptoException e) {
log.error("Could not load key " + keyEntry.toString(), e.getMessage());
throw new RuntimeException("Could not load key " + keyEntry.toString(), e);
}
@ -150,20 +211,44 @@ public class KeyStorage {
}
}
public void saveKeyRing(KeyRing keyRing) {
savePrivateKey(keyRing.getSignatureKeyPair().getPrivate(), KeyEntry.MSG_SIGNATURE.getFileName());
savePrivateKey(keyRing.getEncryptionKeyPair().getPrivate(), KeyEntry.MSG_ENCRYPTION.getFileName());
public void saveKeyRing(KeyRing keyRing, String password) {
savePrivateKey(keyRing.getSignatureKeyPair().getPrivate(), KeyEntry.MSG_SIGNATURE.getFileName(), password);
savePrivateKey(keyRing.getEncryptionKeyPair().getPrivate(), KeyEntry.MSG_ENCRYPTION.getFileName(), password);
}
private void savePrivateKey(PrivateKey privateKey, String name) {
private void savePrivateKey(PrivateKey privateKey, String name, String password) {
if (!storageDir.exists())
//noinspection ResultOfMethodCallIgnored
storageDir.mkdir();
storageDir.mkdirs();
PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(privateKey.getEncoded());
try (FileOutputStream fos = new FileOutputStream(storageDir + "/" + name + ".key")) {
fos.write(pkcs8EncodedKeySpec.getEncoded());
} catch (IOException e) {
byte[] keyBytes = pkcs8EncodedKeySpec.getEncoded();
// Encrypt
if (password != null) {
// Magic
fos.write(ENCRYPTED_FORMAT_MAGIC);
// Version, salt length
ByteBuffer header = ByteBuffer.allocate(ENCRYPTED_FORMAT_LENGTH);
header.putInt(ENCRYPTED_FORMAT_VERSION);
header.putInt(SALT_LENGTH);
fos.write(header.array());
// Salt value
byte[] salt = CryptoUtils.getRandomBytes(SALT_LENGTH);
fos.write(salt);
// Generate secret from password key and salt
KeyCrypterScrypt crypter = ScryptUtil.getKeyCrypterScrypt(salt);
KeyParameter pwKey = ScryptUtil.deriveKeyWithScrypt(crypter, password);
SecretKey secretKey = new SecretKeySpec(pwKey.getKey(), Encryption.SYM_KEY_ALGO);
// Encrypt payload
keyBytes = Encryption.encryptPayloadWithHmac(keyBytes, secretKey);
}
fos.write(keyBytes);
} catch (Exception e) {
log.error("Could not save key " + name, e);
throw new RuntimeException("Could not save key " + name, e);
}

View file

@ -3,17 +3,22 @@ package bisq.common.crypto;
import com.google.inject.Inject;
import com.google.inject.Provider;
/**
* Allows User's static PubKeyRing to be injected into constructors without having to
* open the account yet. Once its opened, PubKeyRingProvider will return non-null PubKeyRing.
* Originally used via bind(PubKeyRing.class).toProvider(PubKeyRingProvider.class);
*/
public class PubKeyRingProvider implements Provider<PubKeyRing> {
private final PubKeyRing pubKeyRing;
private final KeyRing keyRing;
@Inject
public PubKeyRingProvider(KeyRing keyRing) {
pubKeyRing = keyRing.getPubKeyRing();
this.keyRing = keyRing;
}
@Override
public PubKeyRing get() {
return pubKeyRing;
return keyRing.getPubKeyRing();
}
}

View file

@ -0,0 +1,90 @@
/*
* This file is part of Haveno.
*
* Haveno is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Haveno is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Haveno. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.common.crypto;
import bisq.common.UserThread;
import bisq.common.util.Utilities;
import com.google.protobuf.ByteString;
import org.bitcoinj.crypto.KeyCrypterScrypt;
import org.bitcoinj.wallet.Protos;
import org.bouncycastle.crypto.params.KeyParameter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
//TODO: Borrowed form BitcoinJ/Lighthouse. Remove Protos dependency, check complete code logic.
public class ScryptUtil {
private static final Logger log = LoggerFactory.getLogger(ScryptUtil.class);
public interface DeriveKeyResultHandler {
void handleResult(KeyParameter aesKey);
}
public static KeyCrypterScrypt getKeyCrypterScrypt() {
return getKeyCrypterScrypt(KeyCrypterScrypt.randomSalt());
}
public static KeyCrypterScrypt getKeyCrypterScrypt(byte[] salt) {
Protos.ScryptParameters scryptParameters = Protos.ScryptParameters.newBuilder()
.setP(6)
.setR(8)
.setN(32768)
.setSalt(ByteString.copyFrom(salt))
.build();
return new KeyCrypterScrypt(scryptParameters);
}
public static KeyParameter deriveKeyWithScrypt(KeyCrypterScrypt keyCrypterScrypt, String password) {
try {
log.debug("Doing key derivation");
long start = System.currentTimeMillis();
KeyParameter aesKey = keyCrypterScrypt.deriveKey(password);
long duration = System.currentTimeMillis() - start;
log.debug("Key derivation took {} msec", duration);
return aesKey;
} catch (Throwable t) {
t.printStackTrace();
log.error("Key derivation failed. " + t.getMessage());
throw t;
}
}
public static void deriveKeyWithScrypt(KeyCrypterScrypt keyCrypterScrypt, String password, DeriveKeyResultHandler resultHandler) {
Utilities.getThreadPoolExecutor("ScryptUtil:deriveKeyWithScrypt-%d", 1, 2, 5L).submit(() -> {
try {
KeyParameter aesKey = deriveKeyWithScrypt(keyCrypterScrypt, password);
UserThread.execute(() -> {
try {
resultHandler.handleResult(aesKey);
} catch (Throwable t) {
t.printStackTrace();
log.error("Executing task failed. " + t.getMessage());
throw t;
}
});
} catch (Throwable t) {
t.printStackTrace();
log.error("Executing task failed. " + t.getMessage());
throw t;
}
});
}
}

View file

@ -114,7 +114,6 @@ public class PersistenceManager<T extends PersistableEnvelope> {
return;
}
// We don't know from which thread we are called so we map to user thread
UserThread.execute(() -> {
if (doShutdown) {
@ -382,6 +381,11 @@ public class PersistenceManager<T extends PersistableEnvelope> {
return;
}
if (!initCalled.get()) {
log.warn("requestPersistence() called before init. Ignoring request");
return;
}
persistenceRequested = true;
// If we have not initialized yet we postpone the start of the timer and call maybeStartTimerForPersistence at

View file

@ -0,0 +1,128 @@
/*
* This file is part of Haveno.
*
* Haveno is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Haveno is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Haveno. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.common.util;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ZipUtils {
/**
* Zips directory into the output stream. Empty directories are not included.
*
* @param dir The directory to create the zip from.
* @param out The stream to write to.
*/
public static void zipDirToStream(File dir, OutputStream out, int bufferSize) throws Exception {
// Get all files in directory and subdirectories.
ArrayList<String> fileList = new ArrayList<>();
getFilesRecursive(dir, fileList);
try (ZipOutputStream zos = new ZipOutputStream(out)) {
for (String filePath : fileList) {
log.info("Compressing: " + filePath);
// Creates a zip entry.
String name = filePath.substring(dir.getAbsolutePath().length() + 1);
ZipEntry zipEntry = new ZipEntry(name);
zos.putNextEntry(zipEntry);
// Read file content and write to zip output stream.
try (FileInputStream fis = new FileInputStream(filePath)) {
byte[] buffer = new byte[bufferSize];
int length;
while ((length = fis.read(buffer)) > 0) {
zos.write(buffer, 0, length);
}
// Close the zip entry.
zos.closeEntry();
}
}
}
}
/**
* Get files list from the directory recursive to the subdirectory.
*/
public static void getFilesRecursive(File directory, List<String> fileList) {
File[] files = directory.listFiles();
if (files != null && files.length > 0) {
for (File file : files) {
if (file.isFile()) {
fileList.add(file.getAbsolutePath());
} else {
getFilesRecursive(file, fileList);
}
}
}
}
/**
* Unzips the zipStream into the specified directory, overwriting any files.
* Existing files are preserved.
*
* @param dir The directory to write to.
* @param inputStream The raw stream assumed to be in zip format.
* @param bufferSize The buffer used to read from efficiently.
*/
public static void unzipToDir(File dir, InputStream inputStream, int bufferSize) throws Exception {
try (ZipInputStream zipStream = new ZipInputStream(inputStream)) {
ZipEntry entry;
byte[] buffer = new byte[bufferSize];
int count;
while ((entry = zipStream.getNextEntry()) != null) {
File file = new File(dir, entry.getName());
if (entry.isDirectory()) {
file.mkdirs();
} else {
// Make sure folder exists.
file.getParentFile().mkdirs();
log.info("Unzipped file: " + file.getAbsolutePath());
// Don't overwrite the current logs
if ("bisq.log".equals(file.getName())) {
file = new File(file.getParent() + "/" + "bisq.backup.log");
log.info("Unzipped logfile to backup path: " + file.getAbsolutePath());
}
try (FileOutputStream fileOutput = new FileOutputStream(file)) {
while ((count = zipStream.read(buffer)) != -1) {
fileOutput.write(buffer, 0, count);
}
}
}
zipStream.closeEntry();
}
}
}
}