support slip39 seed to mnemonics generation and recovery

This commit is contained in:
Craig Raw 2024-08-07 14:43:26 +02:00
parent f066b5b608
commit ebcee47771
16 changed files with 2615 additions and 12 deletions

View file

@ -3,9 +3,11 @@ package com.sparrowwallet.drongo.wallet;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.wallet.slip39.Slip39MnemonicCode;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.Collectors;
public class DeterministicSeed extends Persistable implements EncryptableItem {
public static final int DEFAULT_SEED_ENTROPY_BITS = 128;
@ -30,7 +32,7 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
}
public DeterministicSeed(List<String> mnemonic, boolean needsPassphrase, long creationTimeSeconds, Type type) {
this.mnemonicCode = mnemonic;
this.mnemonicCode = mnemonic.stream().map(type::lengthen).collect(Collectors.toList());
this.encryptedMnemonicCode = null;
this.needsPassphrase = needsPassphrase;
this.creationTimeSeconds = creationTimeSeconds;
@ -64,6 +66,10 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
* @param creationTimeSeconds When the seed was originally created, UNIX time.
*/
public DeterministicSeed(byte[] entropy, String passphrase, long creationTimeSeconds) {
this(entropy, passphrase, creationTimeSeconds, Type.BIP39);
}
public DeterministicSeed(byte[] entropy, String passphrase, long creationTimeSeconds, Type type) {
if(entropy.length % 4 != 0) {
throw new IllegalArgumentException("Entropy size in bits not divisible by 32");
}
@ -72,13 +78,17 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
throw new IllegalArgumentException("Entropy size too small");
}
if(entropy.length * 8 > MAX_SEED_ENTROPY_BITS) {
throw new IllegalArgumentException("Entropy size too large");
}
if(passphrase == null) {
passphrase = "";
}
try {
this.mnemonicCode = Bip39MnemonicCode.INSTANCE.toMnemonic(entropy);
} catch (MnemonicException.MnemonicLengthException e) {
this.mnemonicCode = type.getMnemonic(entropy, passphrase);
} catch(MnemonicException e) {
// cannot happen
throw new RuntimeException(e);
}
@ -86,7 +96,7 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
this.needsPassphrase = needsPassphrase(passphrase);
this.passphrase = new SecureString(passphrase);
this.creationTimeSeconds = creationTimeSeconds;
this.type = Type.BIP39;
this.type = type;
}
private static boolean needsPassphrase(String passphrase) {
@ -191,7 +201,7 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
}
private byte[] getMnemonicAsBytes() {
SecureString mnemonicString = getMnemonicString();
SecureString mnemonicString = getMnemonicString(true);
if(mnemonicString == null) {
return null;
}
@ -229,6 +239,7 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
DeterministicSeed seed = new DeterministicSeed(mnemonic, needsPassphrase, creationTimeSeconds, type);
seed.setId(getId());
seed.setPassphrase(passphrase);
mnemonic.clear();
return seed;
}
@ -289,10 +300,14 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
/** Get the mnemonic code as string, or null if unknown. */
public SecureString getMnemonicString() {
return getMnemonicString(false);
}
public SecureString getMnemonicString(boolean abbreviated) {
StringBuilder builder = new StringBuilder();
if(mnemonicCode != null) {
for(String word : mnemonicCode) {
builder.append(word);
builder.append(abbreviated ? type.truncate(word) : word);
builder.append(' ');
}
@ -341,7 +356,7 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
if(isEncrypted()) {
seed = new DeterministicSeed(encryptedMnemonicCode.copy(), needsPassphrase, creationTimeSeconds, type);
} else {
seed = new DeterministicSeed(new ArrayList<>(mnemonicCode), needsPassphrase, creationTimeSeconds, type);
seed = new DeterministicSeed(mnemonicCode, needsPassphrase, creationTimeSeconds, type);
}
seed.setId(getId());
@ -362,6 +377,11 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
public byte[] toSeed(List<String> mnemonicCode, String passphrase) {
return Bip39MnemonicCode.toSeed(mnemonicCode, passphrase);
}
@Override
public List<String> getMnemonic(byte[] entropy, String passphrase) throws MnemonicException {
return Bip39MnemonicCode.INSTANCE.toMnemonic(entropy);
}
},
ELECTRUM("Mnemonic Words (Electrum Seed Version System)") {
public byte[] getEntropyBytes(List<String> mnemonicCode) throws MnemonicException {
@ -375,6 +395,39 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
public byte[] toSeed(List<String> mnemonicCode, String passphrase) {
return ElectrumMnemonicCode.toSeed(mnemonicCode, passphrase);
}
@Override
public List<String> getMnemonic(byte[] entropy, String passphrase) throws MnemonicException {
throw new UnsupportedOperationException("Creation of Electrum seed mnemonics is not supported");
}
},
SLIP39("Mnemonic Words (SLIP39)") {
public byte[] getEntropyBytes(List<String> mnemonicCode) throws MnemonicException {
return toSeed(mnemonicCode, "");
}
public void check(List<String> mnemonicCode) throws MnemonicException {
getEntropyBytes(mnemonicCode);
}
public byte[] toSeed(List<String> mnemonicCode, String passphrase) throws MnemonicException {
return Slip39MnemonicCode.INSTANCE.toSeed(mnemonicCode, passphrase);
}
@Override
public List<String> getMnemonic(byte[] entropy, String passphrase) throws MnemonicException {
return Slip39MnemonicCode.INSTANCE.toSingleShareMnemonic(entropy, passphrase);
}
@Override
public String truncate(String word) {
return Slip39MnemonicCode.truncate(word);
}
@Override
public String lengthen(String abbreviation) {
return Slip39MnemonicCode.lengthen(abbreviation);
}
};
Type(String name) {
@ -391,6 +444,16 @@ public class DeterministicSeed extends Persistable implements EncryptableItem {
public abstract void check(List<String> mnemonicCode) throws MnemonicException;
public abstract byte[] toSeed(List<String> mnemonicCode, String passphrase);
public abstract byte[] toSeed(List<String> mnemonicCode, String passphrase) throws MnemonicException;
public abstract List<String> getMnemonic(byte[] entropy, String passphrase) throws MnemonicException;
public String truncate(String word) {
return word;
}
public String lengthen(String abbreviation) {
return abbreviation;
}
}
}

View file

@ -1,20 +1,36 @@
package com.sparrowwallet.drongo.wallet;
public class MnemonicException extends Exception {
private final String title;
public MnemonicException() {
super();
this.title = null;
}
public MnemonicException(String msg) {
super(msg);
public MnemonicException(String message) {
this(message, message);
}
public MnemonicException(String title, String message) {
super(message);
this.title = title;
}
public String getTitle() {
return title;
}
/**
* Thrown when an argument to MnemonicCode is the wrong length.
*/
public static class MnemonicLengthException extends MnemonicException {
public MnemonicLengthException(String msg) {
super(msg);
public MnemonicLengthException(String message) {
super(message);
}
public MnemonicLengthException(String title, String message) {
super(title, message);
}
}
@ -25,6 +41,10 @@ public class MnemonicException extends Exception {
public MnemonicChecksumException() {
super();
}
public MnemonicChecksumException(String title, String message) {
super(title, message);
}
}
/**

View file

@ -0,0 +1,92 @@
package com.sparrowwallet.drongo.wallet.slip39;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import static com.sparrowwallet.drongo.wallet.slip39.Share.*;
import static com.sparrowwallet.drongo.wallet.slip39.Utils.concatenate;
public class Cipher {
public static byte[] xor(byte[] a, byte[] b) {
byte[] result = new byte[a.length];
for(int i = 0; i < a.length; i++) {
result[i] = (byte) (a[i] ^ b[i]);
}
return result;
}
public static byte[] roundFunction(int i, byte[] passphrase, int e, byte[] salt, byte[] r) {
int iterations = (BASE_ITERATION_COUNT << e) / ROUND_COUNT;
byte[] input = new byte[1 + passphrase.length];
input[0] = (byte) i;
System.arraycopy(passphrase, 0, input, 1, passphrase.length);
try {
PBEKeySpec spec = new PBEKeySpec(new String(input, StandardCharsets.UTF_8).toCharArray(), concatenate(salt, r), iterations, r.length * 8);
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
return skf.generateSecret(spec).getEncoded();
} catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
throw new RuntimeException("Error during round function", ex);
}
}
public static byte[] getSalt(int identifier, boolean extendable) {
if(extendable) {
return new byte[0];
}
int identifierLen = Utils.bitsToBytes(ID_LENGTH_BITS);
byte[] idBytes = toByteArray(identifier, identifierLen);
return concatenate(CUSTOMIZATION_STRING_ORIG, idBytes);
}
public static byte[] encrypt(byte[] masterSecret, byte[] passphrase, int iterationExponent, int identifier, boolean extendable) {
if (masterSecret.length % 2 != 0) {
throw new IllegalArgumentException("The length of the master secret in bytes must be an even number.");
}
byte[] l = Arrays.copyOfRange(masterSecret, 0, masterSecret.length / 2);
byte[] r = Arrays.copyOfRange(masterSecret, masterSecret.length / 2, masterSecret.length);
byte[] salt = getSalt(identifier, extendable);
for (int i = 0; i < ROUND_COUNT; i++) {
byte[] f = roundFunction(i, passphrase, iterationExponent, salt, r);
byte[] newR = xor(l, f);
l = r;
r = newR;
}
return concatenate(r, l);
}
public static byte[] decrypt(byte[] encryptedMasterSecret, byte[] passphrase, int iterationExponent, int identifier, boolean extendable) {
if (encryptedMasterSecret.length % 2 != 0) {
throw new IllegalArgumentException("The length of the encrypted master secret in bytes must be an even number.");
}
byte[] l = Arrays.copyOfRange(encryptedMasterSecret, 0, encryptedMasterSecret.length / 2);
byte[] r = Arrays.copyOfRange(encryptedMasterSecret, encryptedMasterSecret.length / 2, encryptedMasterSecret.length);
byte[] salt = getSalt(identifier, extendable);
for (int i = ROUND_COUNT - 1; i >= 0; i--) {
byte[] f = roundFunction(i, passphrase, iterationExponent, salt, r);
byte[] newR = xor(l, f);
l = r;
r = newR;
}
return concatenate(r, l);
}
private static byte[] toByteArray(int value, int length) {
byte[] result = new byte[length];
for (int i = length - 1; i >= 0; i--) {
result[i] = (byte) (value & 0xFF);
value >>= 8;
}
return result;
}
}

View file

@ -0,0 +1,42 @@
package com.sparrowwallet.drongo.wallet.slip39;
import java.util.Arrays;
public class EncryptedMasterSecret {
private final int identifier;
private final boolean extendable;
private final int iterationExponent;
private final byte[] ciphertext;
public EncryptedMasterSecret(int identifier, boolean extendable, int iterationExponent, byte[] ciphertext) {
this.identifier = identifier;
this.extendable = extendable;
this.iterationExponent = iterationExponent;
this.ciphertext = Arrays.copyOf(ciphertext, ciphertext.length);
}
public int getIdentifier() {
return identifier;
}
public boolean isExtendable() {
return extendable;
}
public int getIterationExponent() {
return iterationExponent;
}
public byte[] getCiphertext() {
return Arrays.copyOf(ciphertext, ciphertext.length);
}
public static EncryptedMasterSecret fromMasterSecret(byte[] masterSecret, byte[] passphrase, int identifier, boolean extendable, int iterationExponent) {
byte[] ciphertext = Cipher.encrypt(masterSecret, passphrase, iterationExponent, identifier, extendable);
return new EncryptedMasterSecret(identifier, extendable, iterationExponent, ciphertext);
}
public byte[] decrypt(byte[] passphrase) {
return Cipher.decrypt(ciphertext, passphrase, iterationExponent, identifier, extendable);
}
}

View file

@ -0,0 +1,3 @@
package com.sparrowwallet.drongo.wallet.slip39;
public record GroupParams(int threshold, int size) {}

View file

@ -0,0 +1,3 @@
package com.sparrowwallet.drongo.wallet.slip39;
public record RawShare(int index, byte[] value) {}

View file

@ -0,0 +1,177 @@
package com.sparrowwallet.drongo.wallet.slip39;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.sparrowwallet.drongo.wallet.slip39.Share.GROUP_PREFIX_LENGTH_WORDS;
public class RecoveryState {
private static final int UNDETERMINED = -1;
private Share lastShare;
private final Map<Integer, ShareGroup> groups;
private Share.CommonParameters parameters;
public RecoveryState() {
this.lastShare = null;
this.groups = new HashMap<>();
this.parameters = null;
}
public String groupPrefix(int groupIndex) {
if(lastShare == null) {
throw new IllegalStateException("Add at least one share first");
}
Share fakeShare = lastShare.withGroupIndex(groupIndex);
return String.join(" ", fakeShare.getWords().subList(0, GROUP_PREFIX_LENGTH_WORDS));
}
public int[] groupStatus(int groupIndex) {
ShareGroup group = groups.getOrDefault(groupIndex, new ShareGroup());
if(group.isEmpty()) {
return new int[]{0, UNDETERMINED};
}
return new int[]{group.size(), group.getMemberThreshold()};
}
public boolean groupIsComplete(int groupIndex) {
ShareGroup group = groups.getOrDefault(groupIndex, new ShareGroup());
return group.isComplete();
}
public int groupsComplete() {
if(parameters == null) {
return 0;
}
int completeCount = 0;
for(int i = 0; i < parameters.groupCount(); i++) {
if(groupIsComplete(i)) {
completeCount++;
}
}
return completeCount;
}
public boolean isComplete() {
if(parameters == null) {
return false;
}
return groupsComplete() >= parameters.groupThreshold();
}
public boolean matches(Share share) {
if(parameters == null) {
return true;
}
return share.getCommonParameters().equals(parameters);
}
public boolean addShare(Share share) throws MnemonicException {
if(!matches(share)) {
throw new MnemonicException("Not in current set", "This mnemonic is not part of the current set");
}
groups.computeIfAbsent(share.getGroupIndex(), k -> new ShareGroup()).add(share);
lastShare = share;
if(parameters == null) {
parameters = share.getCommonParameters();
}
return true;
}
public boolean contains(Share share) {
if(!matches(share)) {
return false;
}
if(groups.isEmpty()) {
return false;
}
ShareGroup group = groups.getOrDefault(share.getGroupIndex(), new ShareGroup());
return group.contains(share);
}
public byte[] recover(byte[] passphrase) throws MnemonicException {
Map<Integer, ShareGroup> reducedGroups = new HashMap<>();
for(Map.Entry<Integer, ShareGroup> entry : groups.entrySet()) {
int groupIndex = entry.getKey();
ShareGroup group = entry.getValue();
if(group.isComplete()) {
reducedGroups.put(groupIndex, group.getMinimalGroup());
}
if(reducedGroups.size() >= parameters.groupThreshold()) {
break;
}
}
EncryptedMasterSecret encryptedMasterSecret = Shamir.recoverEms(reducedGroups);
return encryptedMasterSecret.decrypt(passphrase);
}
public String getStatus() {
StringBuilder status = new StringBuilder();
if(parameters.groupCount() > 1) {
status.append("Completed ").append(groupsComplete()).append(" of ").append(parameters.groupThreshold()).append(" groups needed:\n");
}
for(int i = 0; i < parameters.groupCount(); i++) {
status.append(getGroupStatus(i));
if(i < parameters.groupCount() - 1) {
status.append("\n");
}
}
return status.toString();
}
public String getGroupStatus(int index) {
int[] groupStatus = groupStatus(index);
int groupSize = groupStatus[0];
int groupThreshold = groupStatus[1];
String groupPrefix = groupPrefix(index);
if(groupSize == 0) {
return groupSize + " shares from group " + groupPrefix;
} else {
return groupSize + " of " + groupThreshold + " shares needed from group " + groupPrefix;
}
}
public String getShortStatus() {
StringBuilder status = new StringBuilder();
if(parameters.groupCount() > 1) {
status.append(groupsComplete()).append(" of ").append(parameters.groupThreshold()).append(" groups, ");
}
List<String> groupStatuses = new ArrayList<>();
for(int i = 0; i < parameters.groupCount(); i++) {
String groupStatus = getGroupShortStatus(i);
if(!groupStatus.isEmpty()) {
groupStatuses.add(groupStatus);
}
}
status.append(String.join(", ", groupStatuses));
return status.toString();
}
public String getGroupShortStatus(int index) {
int[] groupStatus = groupStatus(index);
int groupSize = groupStatus[0];
int groupThreshold = groupStatus[1];
if(groupSize > 0) {
return groupSize + " of " + groupThreshold + " shares";
}
return "";
}
}

View file

@ -0,0 +1,63 @@
package com.sparrowwallet.drongo.wallet.slip39;
import java.util.ArrayList;
import java.util.List;
import static com.sparrowwallet.drongo.wallet.slip39.Share.CHECKSUM_LENGTH_WORDS;
public class Rs1024 {
private static final int[] GEN = {
0xE0E040,
0x1C1C080,
0x3838100,
0x7070200,
0xE0E0009,
0x1C0C2412,
0x38086C24,
0x3090FC48,
0x21B1F890,
0x3F3F120
};
private static int polymod(List<Integer> values) {
int chk = 1;
for(int v : values) {
int b = chk >> 20;
chk = (chk & 0xFFFFF) << 10 ^ v;
for(int i = 0; i < 10; i++) {
if(((b >> i) & 1) != 0) {
chk ^= GEN[i];
}
}
}
return chk;
}
public static List<Integer> createChecksum(List<Integer> data, byte[] customizationString) {
List<Integer> values = new ArrayList<>();
for(byte b : customizationString) {
values.add((int) b & 0xFF);
}
values.addAll(data);
for(int i = 0; i < CHECKSUM_LENGTH_WORDS; i++) {
values.add(0);
}
int polymod = polymod(values) ^ 1;
List<Integer> checksum = new ArrayList<>(CHECKSUM_LENGTH_WORDS);
for(int i = CHECKSUM_LENGTH_WORDS - 1; i >= 0; i--) {
checksum.add((polymod >> (10 * i)) & 1023);
}
return checksum;
}
public static boolean verifyChecksum(List<Integer> data, byte[] customizationString) {
List<Integer> values = new ArrayList<>();
for(byte b : customizationString) {
values.add((int) b & 0xFF);
}
values.addAll(data);
return polymod(values) == 1;
}
}

View file

@ -0,0 +1,320 @@
package com.sparrowwallet.drongo.wallet.slip39;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.*;
import static com.sparrowwallet.drongo.wallet.slip39.Share.*;
import static com.sparrowwallet.drongo.wallet.slip39.Utils.*;
public class Shamir {
private static final Table TABLE = precomputeExpLog();
private static final Random RANDOM = new SecureRandom();
private static Table precomputeExpLog() {
int[] exp = new int[255];
int[] log = new int[256];
int poly = 1;
for (int i = 0; i < 255; i++) {
exp[i] = poly;
log[poly] = i;
// Multiply poly by the polynomial x + 1.
poly = (poly << 1) ^ poly;
// Reduce poly by x^8 + x^4 + x^3 + x + 1.
if ((poly & 0x100) != 0) {
poly ^= 0x11B;
}
}
return new Table(exp, log);
}
public static byte[] interpolate(List<RawShare> shares, int x) throws MnemonicException {
Set<Integer> xCoordinates = new HashSet<>();
for(RawShare share : shares) {
xCoordinates.add(share.index());
}
if(xCoordinates.size() != shares.size()) {
throw new MnemonicException("Duplicate share", "Invalid set of shares, share indices must be unique");
}
Set<Integer> shareValueLengths = new HashSet<>();
for(RawShare share : shares) {
shareValueLengths.add(share.value().length);
}
if(shareValueLengths.size() != 1) {
throw new MnemonicException("Mismatched length", "Invalid set of shares, all share values must have the same length");
}
if(xCoordinates.contains(x)) {
for(RawShare share : shares) {
if(share.index() == x) {
return share.value();
}
}
}
int logProd = 0;
for(RawShare share : shares) {
logProd += TABLE.log[share.index() ^ x];
}
byte[] result = new byte[shareValueLengths.iterator().next()];
for(RawShare share : shares) {
int logBasisEval = Math.floorMod(logProd - TABLE.log[share.index() ^ x] - shares.stream().mapToInt(s -> TABLE.log[share.index() ^ s.index()]).sum(), 255);
byte[] shareData = share.value();
for(int i = 0; i < result.length; i++) {
int shareVal = Byte.toUnsignedInt(shareData[i]);
int intermediateSum = Byte.toUnsignedInt(result[i]);
result[i] = (byte) (intermediateSum ^ (shareVal != 0 ? TABLE.exp[(TABLE.log[shareVal] + logBasisEval) % 255] : 0));
}
}
return result;
}
public static byte[] createDigest(byte[] randomData, byte[] sharedSecret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(randomData, "HmacSHA256");
mac.init(secretKeySpec);
byte[] fullDigest = mac.doFinal(sharedSecret);
return Arrays.copyOfRange(fullDigest, 0, DIGEST_LENGTH_BYTES);
} catch(NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("Error creating digest", e);
}
}
private static byte[] randomBytes(int length) {
byte[] bytes = new byte[length];
RANDOM.nextBytes(bytes);
return bytes;
}
public static List<RawShare> splitSecret(int threshold, int shareCount, byte[] sharedSecret) throws MnemonicException {
if(threshold < 1) {
throw new IllegalArgumentException("The requested threshold must be a positive integer.");
}
if(threshold > shareCount) {
throw new IllegalArgumentException("The requested threshold must not exceed the number of shares.");
}
if(shareCount > MAX_SHARE_COUNT) {
throw new IllegalArgumentException("The requested number of shares must not exceed " + MAX_SHARE_COUNT + ".");
}
List<RawShare> shares = new ArrayList<>();
// If the threshold is 1, then the digest of the shared secret is not used.
if(threshold == 1) {
for(int i = 0; i < shareCount; i++) {
shares.add(new RawShare(i, sharedSecret));
}
return shares;
}
int randomShareCount = threshold - 2;
for(int i = 0; i < randomShareCount; i++) {
shares.add(new RawShare(i, randomBytes(sharedSecret.length)));
}
byte[] randomPart = randomBytes(sharedSecret.length - DIGEST_LENGTH_BYTES);
byte[] digest = createDigest(randomPart, sharedSecret);
List<RawShare> baseShares = new ArrayList<>(shares);
baseShares.add(new RawShare(DIGEST_INDEX, concatenate(digest, randomPart)));
baseShares.add(new RawShare(SECRET_INDEX, sharedSecret));
for(int i = randomShareCount; i < shareCount; i++) {
shares.add(new RawShare(i, interpolate(baseShares, i)));
}
return shares;
}
public static byte[] recoverSecret(int threshold, List<RawShare> shares) throws MnemonicException {
// If the threshold is 1, then the digest of the shared secret is not used.
if(threshold == 1) {
return shares.iterator().next().value();
}
byte[] sharedSecret = interpolate(shares, SECRET_INDEX);
byte[] digestShare = interpolate(shares, DIGEST_INDEX);
byte[] digest = new byte[DIGEST_LENGTH_BYTES];
byte[] randomPart = new byte[digestShare.length - DIGEST_LENGTH_BYTES];
System.arraycopy(digestShare, 0, digest, 0, DIGEST_LENGTH_BYTES);
System.arraycopy(digestShare, DIGEST_LENGTH_BYTES, randomPart, 0, digestShare.length - DIGEST_LENGTH_BYTES);
if(!MessageDigest.isEqual(digest, createDigest(randomPart, sharedSecret))) {
throw new MnemonicException("Invalid digest", "Invalid digest of the shared secret");
}
return sharedSecret;
}
public static Map<Integer, ShareGroup> decodeMnemonics(Iterable<String> mnemonics) throws MnemonicException {
Set<Share.CommonParameters> commonParams = new HashSet<>();
Map<Integer, ShareGroup> groups = new HashMap<>();
for(String mnemonic : mnemonics) {
Share share = Share.fromMnemonic(mnemonic);
commonParams.add(share.getCommonParameters());
groups.computeIfAbsent(share.getGroupIndex(), k -> new ShareGroup()).add(share);
}
if(commonParams.size() != 1) {
throw new MnemonicException("Mismatched parameters", "Invalid set of mnemonics, all mnemonics must begin with the same " + ID_EXP_LENGTH_WORDS + " words, "
+ "must have the same group threshold and the same group count");
}
return groups;
}
public static List<List<Share>> splitEms(int groupThreshold, List<GroupParams> groups, EncryptedMasterSecret encryptedMasterSecret) throws MnemonicException {
if(encryptedMasterSecret.getCiphertext().length * 8 < MIN_STRENGTH_BITS) {
throw new IllegalArgumentException("The length of the master secret must be at least " + bitsToBytes(MIN_STRENGTH_BITS) + " bytes.");
}
if(groupThreshold > groups.size()) {
throw new IllegalArgumentException("The requested group threshold must not exceed the number of groups.");
}
for(GroupParams group : groups) {
if(group.threshold() == 1 && group.size() > 1) {
throw new IllegalArgumentException("Creating multiple member shares with member threshold 1 is not allowed. Use 1-of-1 member sharing instead.");
}
}
List<RawShare> groupShares = splitSecret(groupThreshold, groups.size(), encryptedMasterSecret.getCiphertext());
List<List<Share>> mnemonicShares = new ArrayList<>();
for(int i = 0; i < groups.size(); i++) {
GroupParams groupParams = groups.get(i);
RawShare groupSecretShare = groupShares.get(i);
List<Share> groupMnemonics = new ArrayList<>();
List<RawShare> memberShares = splitSecret(groupParams.threshold(), groupParams.size(), groupSecretShare.value());
for(int k = 0; k < memberShares.size(); k++) {
RawShare memberShare = memberShares.get(k);
Share share = new Share(
encryptedMasterSecret.getIdentifier(),
encryptedMasterSecret.isExtendable(),
encryptedMasterSecret.getIterationExponent(),
i, // group index
groupThreshold,
groups.size(),
k, // member index
groupParams.threshold(),
memberShare.value()
);
groupMnemonics.add(share);
}
mnemonicShares.add(groupMnemonics);
}
return mnemonicShares;
}
public static int randomIdentifier() {
byte[] randomBytes = randomBytes(bitsToBytes(ID_LENGTH_BITS));
int identifier = byteArrayToInt(randomBytes);
return identifier & ((1 << ID_LENGTH_BITS) - 1);
}
public static List<List<String>> generateMnemonics(int groupThreshold, List<GroupParams> groups, byte[] masterSecret, byte[] passphrase, boolean extendable, int iterationExponent) throws MnemonicException {
// Validate passphrase
for(byte c : passphrase) {
if(c < ASCII_MIN || c > ASCII_MAX) {
throw new IllegalArgumentException("The passphrase must contain only printable ASCII characters (code points 32-126).");
}
}
// Generate random identifier
int identifier = randomIdentifier();
// Encrypt master secret
EncryptedMasterSecret encryptedMasterSecret = EncryptedMasterSecret.fromMasterSecret(masterSecret, passphrase, identifier, extendable, iterationExponent);
// Split encrypted master secret into mnemonic shares
List<List<Share>> groupedShares = splitEms(groupThreshold, groups, encryptedMasterSecret);
// Convert shares to mnemonics
List<List<String>> mnemonics = new ArrayList<>();
for(List<Share> group : groupedShares) {
List<String> groupMnemonics = new ArrayList<>();
for(Share share : group) {
groupMnemonics.add(share.getMnemonic());
}
mnemonics.add(groupMnemonics);
}
return mnemonics;
}
public static EncryptedMasterSecret recoverEms(Map<Integer, ShareGroup> groups) throws MnemonicException {
// Check if groups is empty
if(groups.isEmpty()) {
throw new MnemonicException("No shares", "The set of shares is empty");
}
// Get common parameters from the first group
Share.CommonParameters params = groups.values().iterator().next().getCommonParameters();
// Check if the number of groups meets the required threshold
if(groups.size() < params.groupThreshold()) {
throw new MnemonicException("Insufficient groups", String.format("Insufficient number of mnemonic groups, the required number of groups is %d", params.groupThreshold()));
}
// Check if the number of groups matches the required threshold
if(groups.size() != params.groupThreshold()) {
throw new MnemonicException("Too many groups", String.format("Wrong number of mnemonic groups, expected %d groups, but %d were provided", params.groupThreshold(), groups.size()));
}
// Validate each group has the correct number of shares
for(Map.Entry<Integer, ShareGroup> entry : groups.entrySet()) {
ShareGroup group = entry.getValue();
if(group.size() != group.getMemberThreshold()) {
String prefix = String.join(" ", group.iterator().next().getWords().subList(0, GROUP_PREFIX_LENGTH_WORDS));
throw new MnemonicException("Group shares mismatch", String.format("Wrong number of mnemonics, expected %d mnemonics starting with \"%s ...\", but %d were provided", group.getMemberThreshold(), prefix, group.size()));
}
}
// Recover shares from groups
List<RawShare> groupShares = new ArrayList<>();
for(Map.Entry<Integer, ShareGroup> entry : groups.entrySet()) {
groupShares.add(new RawShare(entry.getKey(), recoverSecret(entry.getValue().getMemberThreshold(), entry.getValue().toRawShares())));
}
// Recover the encrypted master secret
byte[] ciphertext = recoverSecret(params.groupThreshold(), groupShares);
return new EncryptedMasterSecret(params.identifier(), params.extendable(), params.iterationExponent(), ciphertext);
}
public static byte[] combineMnemonics(Iterable<String> mnemonics, byte[] passphrase) throws MnemonicException {
if(!mnemonics.iterator().hasNext()) {
throw new MnemonicException("No shares", "The list of mnemonics is empty");
}
Map<Integer, ShareGroup> groups = decodeMnemonics(mnemonics);
EncryptedMasterSecret encryptedMasterSecret = recoverEms(groups);
return encryptedMasterSecret.decrypt(passphrase);
}
private record Table(int[] exp, int[] log) {}
}

View file

@ -0,0 +1,208 @@
package com.sparrowwallet.drongo.wallet.slip39;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.StringJoiner;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.wallet.slip39.Utils.*;
public class Share {
private static final int RADIX = 1024;
public static final int ID_LENGTH_BITS = 15;
private static final int ITERATION_EXP_LENGTH_BITS = 4;
private static final int EXTENDABLE_FLAG_LENGTH_BITS = 1;
public static final int ID_EXP_LENGTH_WORDS = bitsToWords(ID_LENGTH_BITS + EXTENDABLE_FLAG_LENGTH_BITS + ITERATION_EXP_LENGTH_BITS);
public static final int GROUP_PREFIX_LENGTH_WORDS = ID_EXP_LENGTH_WORDS + 1;
public static final int CHECKSUM_LENGTH_WORDS = 3;
public static final int MIN_STRENGTH_BITS = 128;
private static final int METADATA_LENGTH_WORDS = ID_EXP_LENGTH_WORDS + 2 + CHECKSUM_LENGTH_WORDS;
private static final int MIN_MNEMONIC_LENGTH_WORDS = METADATA_LENGTH_WORDS + bitsToWords(MIN_STRENGTH_BITS);
public static final byte[] CUSTOMIZATION_STRING_ORIG = "shamir".getBytes(StandardCharsets.US_ASCII);
public static final byte[] CUSTOMIZATION_STRING_EXTENDABLE = "shamir_extendable".getBytes(StandardCharsets.US_ASCII);
public static final int ROUND_COUNT = 4;
public static final int BASE_ITERATION_COUNT = 10000;
public static final int DIGEST_LENGTH_BYTES = 4;
public static final int MAX_SHARE_COUNT = 16;
public static final int DIGEST_INDEX = 254;
public static final int SECRET_INDEX = 255;
public static final int ASCII_MIN = 32;
public static final int ASCII_MAX = 126;
private final int identifier;
private final boolean extendable;
private final int iterationExponent;
private final int groupIndex;
private final int groupThreshold;
private final int groupCount;
private final int index;
private final int memberThreshold;
private final byte[] value;
public Share(int identifier, boolean extendable, int iterationExponent, int groupIndex, int groupThreshold, int groupCount, int index, int memberThreshold, byte[] value) {
this.identifier = identifier;
this.extendable = extendable;
this.iterationExponent = iterationExponent;
this.groupIndex = groupIndex;
this.groupThreshold = groupThreshold;
this.groupCount = groupCount;
this.index = index;
this.memberThreshold = memberThreshold;
this.value = value;
}
public CommonParameters getCommonParameters() {
return new CommonParameters(identifier, extendable, iterationExponent, groupThreshold, groupCount);
}
public GroupParameters getGroupParameters() {
return new GroupParameters(identifier, extendable, iterationExponent, groupIndex, groupThreshold, groupCount, memberThreshold);
}
public int getIndex() {
return index;
}
public byte[] getValue() {
return value;
}
public int getMemberThreshold() {
return memberThreshold;
}
public int getGroupIndex() {
return groupIndex;
}
private List<Integer> encodeIdExp() {
int idExp = identifier << (ITERATION_EXP_LENGTH_BITS + EXTENDABLE_FLAG_LENGTH_BITS);
idExp += (extendable ? 1 : 0) << ITERATION_EXP_LENGTH_BITS;
idExp += iterationExponent;
return intToWordIndices(BigInteger.valueOf(idExp), ID_EXP_LENGTH_WORDS);
}
private List<Integer> encodeShareParams() {
//each value is 4 bits, for 20 bits total
int val = groupIndex;
val <<= 4;
val += groupThreshold - 1;
val <<= 4;
val += groupCount - 1;
val <<= 4;
val += index;
val <<= 4;
val += memberThreshold - 1;
//group parameters are 2 words
return intToWordIndices(BigInteger.valueOf(val), 2);
}
public List<String> getWords() {
int valueWordCount = bitsToWords(value.length * 8);
BigInteger valueInt = new BigInteger(1, value);
List<Integer> valueData = intToWordIndices(valueInt, valueWordCount);
List<Integer> shareData = new ArrayList<>(encodeIdExp());
shareData.addAll(encodeShareParams());
shareData.addAll(valueData);
List<Integer> checksum = Rs1024.createChecksum(shareData, getCustomizationString(extendable));
shareData.addAll(checksum);
return Slip39MnemonicCode.INSTANCE.getWordsFromIndices(shareData);
}
public String getMnemonic() {
StringJoiner joiner = new StringJoiner(" ");
getWords().forEach(joiner::add);
return joiner.toString();
}
public static Share fromMnemonic(List<String> mnemonic) throws MnemonicException {
return fromMnemonic(String.join(" ", mnemonic));
}
public static Share fromMnemonic(String mnemonic) throws MnemonicException {
List<Integer> mnemonicData = Slip39MnemonicCode.INSTANCE.getIndicesFromMnemonic(mnemonic);
if(mnemonicData.size() < MIN_MNEMONIC_LENGTH_WORDS) {
throw new MnemonicException("Too few words", "Invalid mnemonic length, the length of each mnemonic must be at least " + MIN_MNEMONIC_LENGTH_WORDS + " words");
}
int paddingLen = (RADIX_BITS * (mnemonicData.size() - METADATA_LENGTH_WORDS)) % 16;
if(paddingLen > 8) {
throw new MnemonicException("Invalid mnemonic", "Invalid mnemonic length, padding of " + paddingLen);
}
List<Integer> idExpData = mnemonicData.subList(0, ID_EXP_LENGTH_WORDS);
int idExp = intFromWordIndices(idExpData).intValue();
int identifier = idExp >> (EXTENDABLE_FLAG_LENGTH_BITS + ITERATION_EXP_LENGTH_BITS);
boolean extendable = ((idExp >> ITERATION_EXP_LENGTH_BITS) & 1) > 0;
int iterationExponent = idExp & ((1 << ITERATION_EXP_LENGTH_BITS) - 1);
if(!Rs1024.verifyChecksum(mnemonicData, getCustomizationString(extendable))) {
throw new MnemonicException("Invalid checksum", "Invalid mnemonic checksum for " + Arrays.stream(mnemonic.split(" ")).limit(ID_EXP_LENGTH_WORDS + 2).collect(Collectors.joining(" ")));
}
List<Integer> shareParamsData = mnemonicData.subList(ID_EXP_LENGTH_WORDS, ID_EXP_LENGTH_WORDS + 2);
BigInteger shareParamsInt = intFromWordIndices(shareParamsData);
List<Integer> shareParams = intToIndices(shareParamsInt, 5, 4);
int groupIndex = shareParams.get(0);
int groupThreshold = shareParams.get(1);
int groupCount = shareParams.get(2);
int index = shareParams.get(3);
int memberThreshold = shareParams.get(4);
if(groupCount < groupThreshold) {
throw new MnemonicException("Invalid mnemonic", "Invalid mnemonic, group threshold cannot be greater than group count");
}
List<Integer> valueData = mnemonicData.subList(ID_EXP_LENGTH_WORDS + 2, mnemonicData.size() - CHECKSUM_LENGTH_WORDS);
int valueByteCount = bitsToBytes(RADIX_BITS * valueData.size() - paddingLen);
byte[] value = intFromWordIndices(valueData).toByteArray();
if(value.length == valueByteCount + 1 && value[0] == 0) {
value = Arrays.copyOfRange(value, 1, value.length);
}
if(value.length > valueByteCount) {
throw new MnemonicException("Invalid mnemonic", "Invalid mnemonic padding");
}
return new Share(identifier, extendable, iterationExponent, groupIndex, groupThreshold + 1, groupCount + 1, index, memberThreshold + 1, value);
}
public static List<Integer> intToWordIndices(BigInteger value, int length) {
return intToIndices(value, length, RADIX_BITS);
}
public static BigInteger intFromWordIndices(List<Integer> indices) {
BigInteger value = BigInteger.valueOf(0);
for(int index : indices) {
value = value.multiply(BigInteger.valueOf(RADIX)).add(BigInteger.valueOf(index));
}
return value;
}
public static byte[] getCustomizationString(boolean extendable) {
return extendable ? CUSTOMIZATION_STRING_EXTENDABLE : CUSTOMIZATION_STRING_ORIG;
}
public Share withGroupIndex(int groupIndex) {
return new Share(identifier, extendable, iterationExponent, groupIndex, groupThreshold, groupCount, index, memberThreshold, value);
}
public record CommonParameters(int identifier, boolean extendable, int iterationExponent, int groupThreshold, int groupCount) {}
public record GroupParameters(int identifier, boolean extendable, int iterationExponent, int groupIndex, int groupThreshold, int groupCount, int memberThreshold) {}
}

View file

@ -0,0 +1,70 @@
package com.sparrowwallet.drongo.wallet.slip39;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import java.util.*;
public class ShareGroup {
private final Set<Share> shares;
public ShareGroup() {
this.shares = new HashSet<>();
}
public Iterator<Share> iterator() {
return this.shares.iterator();
}
public int size() {
return this.shares.size();
}
public boolean isEmpty() {
return this.shares.isEmpty();
}
public boolean contains(Share share) {
return this.shares.contains(share);
}
public void add(Share share) throws MnemonicException {
if(!this.shares.isEmpty() && !this.getGroupParameters().equals(share.getGroupParameters())) {
throw new MnemonicException("Invalid mnemonic", "Invalid set of mnemonics, group parameters don't match.");
}
this.shares.add(share);
}
public List<RawShare> toRawShares() {
List<RawShare> rawShares = new ArrayList<>();
for(Share s : this.shares) {
rawShares.add(new RawShare(s.getIndex(), s.getValue()));
}
return rawShares;
}
public ShareGroup getMinimalGroup() {
ShareGroup group = new ShareGroup();
int threshold = this.getMemberThreshold();
Iterator<Share> iterator = this.shares.iterator();
while(group.shares.size() < threshold && iterator.hasNext()) {
group.shares.add(iterator.next());
}
return group;
}
public Share.CommonParameters getCommonParameters() {
return this.shares.iterator().next().getCommonParameters();
}
public Share.GroupParameters getGroupParameters() {
return this.shares.iterator().next().getGroupParameters();
}
public int getMemberThreshold() {
return this.shares.iterator().next().getMemberThreshold();
}
public boolean isComplete() {
return !this.shares.isEmpty() && this.shares.size() >= this.getMemberThreshold();
}
}

View file

@ -0,0 +1,125 @@
package com.sparrowwallet.drongo.wallet.slip39;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class Slip39MnemonicCode {
private static final Logger log = LoggerFactory.getLogger(Slip39MnemonicCode.class);
public static final int MAX_ABBREVIATED_WORD_LENGTH = 4;
private final ArrayList<String> wordList;
private final Map<Integer, String> wordIndexMap;
private static final String SLIP39_RESOURCE_NAME = "/wordlist/slip39.txt";
static {
try {
INSTANCE = new Slip39MnemonicCode();
} catch (RuntimeException e) {
log.error("Failed to load word list", e);
}
}
public static Slip39MnemonicCode INSTANCE;
public Slip39MnemonicCode() {
this(openDefaultWords());
}
private static InputStream openDefaultWords() {
InputStream stream = Slip39MnemonicCode.class.getResourceAsStream(SLIP39_RESOURCE_NAME);
if(stream == null) {
throw new RuntimeException(new FileNotFoundException(SLIP39_RESOURCE_NAME));
}
return stream;
}
public Slip39MnemonicCode(InputStream wordstream) throws IllegalArgumentException {
try(BufferedReader br = new BufferedReader(new InputStreamReader(wordstream, StandardCharsets.UTF_8))) {
this.wordList = new ArrayList<>(1024);
String word;
while((word = br.readLine()) != null) {
this.wordList.add(word);
}
if(this.wordList.size() != 1024) {
throw new IllegalArgumentException("Input stream did not contain 2048 words");
}
this.wordIndexMap = new HashMap<>(1024);
for(int i = 0; i < wordList.size(); i++) {
this.wordIndexMap.put(i, wordList.get(i));
}
} catch (IOException e) {
throw new RuntimeException("Error loading word list", e);
}
}
public List<String> getWordList() {
return Collections.unmodifiableList(wordList);
}
public List<String> getWordsFromIndices(List<Integer> indices) {
return indices.stream().map(wordIndexMap::get).toList();
}
public String getMnemonicFromIndices(List<Integer> indices) {
StringJoiner joiner = new StringJoiner(" ");
getWordsFromIndices(indices).forEach(joiner::add);
return joiner.toString();
}
public List<Integer> getIndicesFromMnemonic(String mnemonic) {
String[] words = mnemonic.split(" ");
return Arrays.stream(words).map(wordList::indexOf).toList();
}
public byte[] toSeed(List<String> mnemonicCode, String passphrase) throws MnemonicException {
Share share = Share.fromMnemonic(mnemonicCode);
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(share);
return recoveryState.recover(getPassphraseBytes(passphrase));
}
public List<String> toSingleShareMnemonic(byte[] masterSecret, String passphrase) throws MnemonicException {
byte[] passphraseBytes = getPassphraseBytes(passphrase);
List<List<String>> groupShares = Shamir.generateMnemonics(1, List.of(new GroupParams(1, 1)), masterSecret, passphraseBytes, true, 1);
List<String> firstGroup = groupShares.get(0);
String firstShare = firstGroup.get(0);
return Arrays.asList(firstShare.split(" "));
}
public static byte[] getPassphraseBytes(String passphrase) throws MnemonicException {
byte[] passphraseBytes = passphrase.getBytes(StandardCharsets.UTF_8);
for(byte passphraseByte : passphraseBytes) {
if(passphraseByte < 32 || passphraseByte > 126) {
throw new MnemonicException("Unprintable passphrase character");
}
}
return passphraseBytes;
}
public static String truncate(String word) {
return word.length() > MAX_ABBREVIATED_WORD_LENGTH ? word.substring(0, MAX_ABBREVIATED_WORD_LENGTH) : word;
}
public static String lengthen(String abbreviation) {
if(abbreviation.length() == MAX_ABBREVIATED_WORD_LENGTH) {
for(String word : Slip39MnemonicCode.INSTANCE.getWordList()) {
if(word.startsWith(abbreviation)) {
return word;
}
}
}
return abbreviation;
}
}

View file

@ -0,0 +1,54 @@
package com.sparrowwallet.drongo.wallet.slip39;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Utils {
public static final int RADIX_BITS = 10;
public static int roundBits(int n, int radixBits) {
// Get the number of `radixBits`-sized digits required to store a `n`-bit value.
return (n + radixBits - 1) / radixBits;
}
public static int bitsToBytes(int n) {
// Round up bit count to whole bytes.
return roundBits(n, 8);
}
public static int bitsToWords(int n) {
// Round up bit count to a multiple of word size.
return roundBits(n, RADIX_BITS);
}
public static List<Integer> intToIndices(BigInteger value, int length, int radixBits) {
// Convert an integer value to indices in big endian order.
BigInteger mask = BigInteger.ONE.shiftLeft(radixBits).subtract(BigInteger.ONE);
List<Integer> indices = new ArrayList<>(length);
for(int i = length - 1; i >= 0; i--) {
indices.add((value.shiftRight(i * radixBits)).and(mask).intValue());
}
return indices;
}
public static byte[] concatenate(byte[]... arrays) {
int totalLength = Arrays.stream(arrays).mapToInt(a -> a.length).sum();
byte[] result = new byte[totalLength];
int offset = 0;
for(byte[] array : arrays) {
System.arraycopy(array, 0, result, offset, array.length);
offset += array.length;
}
return result;
}
public static int byteArrayToInt(byte[] bytes) {
int value = 0;
for(byte b : bytes) {
value = (value << 8) | (b & 0xFF);
}
return value;
}
}

View file

@ -17,5 +17,6 @@ open module com.sparrowwallet.drongo {
exports com.sparrowwallet.drongo.policy;
exports com.sparrowwallet.drongo.uri;
exports com.sparrowwallet.drongo.bip47;
exports com.sparrowwallet.drongo.wallet.slip39;
exports org.bitcoin;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,338 @@
package com.sparrowwallet.drongo.wallet.slip39;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.List;
public class ShareTest {
@Test
public void test1of1() throws MnemonicException {
String mnemonic = "duckling enlarge academic academic agency result length solution fridge kidney coal piece deal husband erode duke ajar critical decision keyboard";
Share share = Share.fromMnemonic(mnemonic);
Assertions.assertEquals(7945, share.getCommonParameters().identifier());
Assertions.assertFalse(share.getCommonParameters().extendable());
Assertions.assertEquals(0, share.getGroupParameters().iterationExponent());
Assertions.assertEquals(1, share.getGroupParameters().groupCount());
Assertions.assertEquals(1, share.getGroupParameters().groupThreshold());
Assertions.assertEquals(0, share.getGroupParameters().groupIndex());
Assertions.assertEquals(1, share.getGroupParameters().memberThreshold());
Assertions.assertEquals(0, share.getIndex());
Assertions.assertEquals("11bc609d21747c49ba78c0701293e417", HexFormat.of().formatHex(share.getValue()));
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(share);
byte[] secret = recoveryState.recover(getPassphrase());
Assertions.assertEquals("bb54aac4b89dc868ba37d9cc21b2cece", HexFormat.of().formatHex(secret));
List<List<String>> generated = Shamir.generateMnemonics(share.getGroupParameters().groupThreshold(),
List.of(new GroupParams(1, 1)),
secret, getPassphrase(), share.getCommonParameters().extendable(), share.getGroupParameters().iterationExponent());
Assertions.assertEquals(1, generated.size());
Assertions.assertEquals(1, generated.get(0).size());
String generatedMnemonic = String.join(" ", generated.get(0).get(0));
Share generatedShare = Share.fromMnemonic(generatedMnemonic);
Assertions.assertFalse(generatedShare.getCommonParameters().extendable());
Assertions.assertEquals(0, generatedShare.getGroupParameters().iterationExponent());
Assertions.assertEquals(1, generatedShare.getGroupParameters().groupCount());
Assertions.assertEquals(1, generatedShare.getGroupParameters().groupThreshold());
Assertions.assertEquals(0, generatedShare.getGroupParameters().groupIndex());
Assertions.assertEquals(1, generatedShare.getGroupParameters().memberThreshold());
Assertions.assertEquals(0, generatedShare.getIndex());
RecoveryState generatedRecoveryState = new RecoveryState();
generatedRecoveryState.addShare(generatedShare);
byte[] generatedSecret = generatedRecoveryState.recover(getPassphrase());
Assertions.assertEquals("bb54aac4b89dc868ba37d9cc21b2cece", HexFormat.of().formatHex(generatedSecret));
}
private static byte[] getPassphrase() {
return "TREZOR".getBytes(StandardCharsets.US_ASCII);
}
@Test
public void testInvalidChecksum() {
String mnemonic = "duckling enlarge academic academic agency result length solution fridge kidney coal piece deal husband erode duke ajar critical decision kidney";
MnemonicException exception = Assertions.assertThrows(MnemonicException.class, () -> Share.fromMnemonic(mnemonic));
}
@Test
public void testInvalidPadding() {
String mnemonic = "duckling enlarge academic academic email result length solution fridge kidney coal piece deal husband erode duke ajar music cargo fitness";
MnemonicException exception = Assertions.assertThrows(MnemonicException.class, () -> Share.fromMnemonic(mnemonic));
}
@Test
public void test2of3() throws MnemonicException {
String mnemonic1 = "shadow pistol academic always adequate wildlife fancy gross oasis cylinder mustang wrist rescue view short owner flip making coding armed";
Share share1 = Share.fromMnemonic(mnemonic1);
Assertions.assertEquals(25653, share1.getCommonParameters().identifier());
Assertions.assertFalse(share1.getCommonParameters().extendable());
Assertions.assertEquals(2, share1.getGroupParameters().iterationExponent());
Assertions.assertEquals(1, share1.getGroupParameters().groupCount());
Assertions.assertEquals(1, share1.getGroupParameters().groupThreshold());
Assertions.assertEquals(0, share1.getGroupParameters().groupIndex());
Assertions.assertEquals(2, share1.getGroupParameters().memberThreshold());
Assertions.assertEquals(2, share1.getIndex());
Assertions.assertEquals("08fb14b66e692e25dfe2edf53289ed62", HexFormat.of().formatHex(share1.getValue()));
String mnemonic2 = "shadow pistol academic acid actress prayer class unknown daughter sweater depict flip twice unkind craft early superior advocate guest smoking";
Share share2 = Share.fromMnemonic(mnemonic2);
Assertions.assertEquals(25653, share2.getCommonParameters().identifier());
Assertions.assertFalse(share2.getCommonParameters().extendable());
Assertions.assertEquals(2, share2.getGroupParameters().iterationExponent());
Assertions.assertEquals(1, share2.getGroupParameters().groupCount());
Assertions.assertEquals(1, share2.getGroupParameters().groupThreshold());
Assertions.assertEquals(0, share2.getGroupParameters().groupIndex());
Assertions.assertEquals(2, share2.getGroupParameters().memberThreshold());
Assertions.assertEquals(0, share2.getIndex());
Assertions.assertEquals("06ab48fef4bedc8ce58baeef0a73f76e", HexFormat.of().formatHex(share2.getValue()));
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(share1);
recoveryState.addShare(share2);
byte[] secret = recoveryState.recover(getPassphrase());
Assertions.assertEquals("b43ceb7e57a0ea8766221624d01b0864", HexFormat.of().formatHex(secret));
List<List<String>> generated = Shamir.generateMnemonics(share1.getGroupParameters().groupThreshold(),
List.of(new GroupParams(2, 3)),
secret, getPassphrase(), share1.getCommonParameters().extendable(), share1.getGroupParameters().iterationExponent());
Assertions.assertEquals(1, generated.size());
Assertions.assertEquals(3, generated.get(0).size());
String generatedMnemonic1 = String.join(" ", generated.get(0).get(0));
Share generatedShare1 = Share.fromMnemonic(generatedMnemonic1);
Assertions.assertFalse(generatedShare1.getCommonParameters().extendable());
Assertions.assertEquals(2, generatedShare1.getGroupParameters().iterationExponent());
Assertions.assertEquals(1, generatedShare1.getGroupParameters().groupCount());
Assertions.assertEquals(1, generatedShare1.getGroupParameters().groupThreshold());
Assertions.assertEquals(0, generatedShare1.getGroupParameters().groupIndex());
Assertions.assertEquals(2, generatedShare1.getGroupParameters().memberThreshold());
Assertions.assertEquals(0, generatedShare1.getIndex());
String generatedMnemonic2 = String.join(" ", generated.get(0).get(1));
Share generatedShare2 = Share.fromMnemonic(generatedMnemonic2);
Assertions.assertFalse(generatedShare1.getCommonParameters().extendable());
Assertions.assertEquals(2, generatedShare2.getGroupParameters().iterationExponent());
Assertions.assertEquals(1, generatedShare2.getGroupParameters().groupCount());
Assertions.assertEquals(1, generatedShare2.getGroupParameters().groupThreshold());
Assertions.assertEquals(0, generatedShare2.getGroupParameters().groupIndex());
Assertions.assertEquals(2, generatedShare2.getGroupParameters().memberThreshold());
Assertions.assertEquals(1, generatedShare2.getIndex());
RecoveryState generatedRecoveryState = new RecoveryState();
generatedRecoveryState.addShare(generatedShare1);
generatedRecoveryState.addShare(generatedShare2);
byte[] generatedSecret = generatedRecoveryState.recover(getPassphrase());
Assertions.assertEquals("b43ceb7e57a0ea8766221624d01b0864", HexFormat.of().formatHex(generatedSecret));
}
@Test
public void testDifferentIds() throws MnemonicException {
String mnemonic1 = "adequate smoking academic acid debut wine petition glen cluster slow rhyme slow simple epidemic rumor junk tracks treat olympic tolerate";
String mnemonic2 = "adequate stay academic agency agency formal party ting frequent learn upstairs remember smear leaf damage anatomy ladle market hush corner";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.addShare(Share.fromMnemonic(mnemonic2)));
}
@Test
public void testDifferentIterationExps() throws MnemonicException {
String mnemonic1 = "peasant leaves academic acid desert exact olympic math alive axle trial tackle drug deny decent smear dominant desert bucket remind";
String mnemonic2 = "peasant leader academic agency cultural blessing percent network envelope medal junk primary human pumps jacket fragment payroll ticket evoke voice";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.addShare(Share.fromMnemonic(mnemonic2)));
}
@Test
public void testMismatchGroupThresholds() throws MnemonicException {
String mnemonic1 = "liberty category beard echo animal fawn temple briefing math username various wolf aviation fancy visual holy thunder yelp helpful payment";
String mnemonic2 = "liberty category beard email beyond should fancy romp founder easel pink holy hairy romp loyalty material victim owner toxic custody";
String mnemonic3 = "liberty category academic easy being hazard crush diminish oral lizard reaction cluster force dilemma deploy force club veteran expect photo";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
recoveryState.addShare(Share.fromMnemonic(mnemonic2));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.addShare(Share.fromMnemonic(mnemonic3)));
}
@Test
public void testMismatchGroupCounts() throws MnemonicException {
String mnemonic1 = "average senior academic leaf broken teacher expect surface hour capture obesity desire negative dynamic dominant pistol mineral mailman iris aide";
String mnemonic2 = "average senior academic agency curious pants blimp spew clothes slice script dress wrap firm shaft regular slavery negative theater roster";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.addShare(Share.fromMnemonic(mnemonic2)));
}
@Test
public void testGreaterGroupThresholds() throws MnemonicException {
String mnemonic1 = "music husband acrobat acid artist finance center either graduate swimming object bike medical clothes station aspect spider maiden bulb welcome";
RecoveryState recoveryState = new RecoveryState();
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.addShare(Share.fromMnemonic(mnemonic1)));
}
@Test
public void testDuplicateIndices() throws MnemonicException {
String mnemonic1 = "device stay academic always dive coal antenna adult black exceed stadium herald advance soldier busy dryer daughter evaluate minister laser";
String mnemonic2 = "device stay academic always dwarf afraid robin gravity crunch adjust soul branch walnut coastal dream costume scholar mortgage mountain pumps";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
recoveryState.addShare(Share.fromMnemonic(mnemonic2));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.recover(getPassphrase()));
}
@Test
public void mismatchMemberThresholds() throws MnemonicException {
String mnemonic1 = "hour painting academic academic device formal evoke guitar random modern justice filter withdraw trouble identify mailman insect general cover oven";
String mnemonic2 = "hour painting academic agency artist again daisy capital beaver fiber much enjoy suitable symbolic identify photo editor romp float echo";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.addShare(Share.fromMnemonic(mnemonic2)));
}
@Test
public void invalidDigest() throws MnemonicException {
String mnemonic1 = "guilt walnut academic acid deliver remove equip listen vampire tactics nylon rhythm failure husband fatigue alive blind enemy teaspoon rebound";
String mnemonic2 = "guilt walnut academic agency brave hamster hobo declare herd taste alpha slim criminal mild arcade formal romp branch pink ambition";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
recoveryState.addShare(Share.fromMnemonic(mnemonic2));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.recover(getPassphrase()));
}
@Test
public void testInsufficientGroupNumber1() throws MnemonicException {
String mnemonic1 = "eraser senior beard romp adorn nuclear spill corner cradle style ancient family general leader ambition exchange unusual garlic promise voice";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.recover(getPassphrase()));
}
@Test
public void testInsufficientGroupNumber2() throws MnemonicException {
String mnemonic1 = "eraser senior decision scared cargo theory device idea deliver modify curly include pancake both news skin realize vitamins away join";
String mnemonic2 = "eraser senior decision roster beard treat identify grumpy salt index fake aviation theater cubic bike cause research dragon emphasis counter";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
recoveryState.addShare(Share.fromMnemonic(mnemonic2));
Assertions.assertThrows(MnemonicException.class, () -> recoveryState.recover(getPassphrase()));
}
@Test
public void test2of3with256() throws MnemonicException {
String mnemonic1 = "humidity disease academic always aluminum jewelry energy woman receiver strategy amuse duckling lying evidence network walnut tactics forget hairy rebound impulse brother survive clothes stadium mailman rival ocean reward venture always armed unwrap";
Share share1 = Share.fromMnemonic(mnemonic1);
String mnemonic2 = "humidity disease academic agency actress jacket gross physics cylinder solution fake mortgage benefit public busy prepare sharp friar change work slow purchase ruler again tricycle involve viral wireless mixture anatomy desert cargo upgrade";
Share share2 = Share.fromMnemonic(mnemonic2);
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
recoveryState.addShare(Share.fromMnemonic(mnemonic2));
byte[] secret = recoveryState.recover(getPassphrase());
Assertions.assertEquals("c938b319067687e990e05e0da0ecce1278f75ff58d9853f19dcaeed5de104aae", HexFormat.of().formatHex(secret));
}
@Test
public void testInvalidMnemonicLength() throws MnemonicException {
String mnemonic = "junk necklace academic academic acne isolate join hesitate lunar roster dough calcium chemical ladybug amount mobile glasses verify cylinder";
Assertions.assertThrows(MnemonicException.class, () -> Share.fromMnemonic(mnemonic));
}
@Test
public void testInvalidMasterSecret() throws MnemonicException {
String mnemonic = "fraction necklace academic academic award teammate mouse regular testify coding building member verdict purchase blind camera duration email prepare spirit quarter";
Assertions.assertThrows(MnemonicException.class, () -> Share.fromMnemonic(mnemonic));
}
@Test
public void testModularArithmetic() throws MnemonicException {
String mnemonic1 = "herald flea academic cage avoid space trend estate dryer hairy evoke eyebrow improve airline artwork garlic premium duration prevent oven";
String mnemonic2 = "herald flea academic client blue skunk class goat luxury deny presence impulse graduate clay join blanket bulge survive dish necklace";
String mnemonic3 = "herald flea academic acne advance fused brother frozen broken game ranked ajar already believe check install theory angry exercise adult";
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(Share.fromMnemonic(mnemonic1));
recoveryState.addShare(Share.fromMnemonic(mnemonic2));
recoveryState.addShare(Share.fromMnemonic(mnemonic3));
byte[] secret = recoveryState.recover(getPassphrase());
Assertions.assertEquals("ad6f2ad8b59bbbaa01369b9006208d9a", HexFormat.of().formatHex(secret));
}
@Test
public void test1of1extendable() throws MnemonicException {
String mnemonic = "testify swimming academic academic column loyalty smear include exotic bedroom exotic wrist lobe cover grief golden smart junior estimate learn";
Share share = Share.fromMnemonic(mnemonic);
Assertions.assertTrue(share.getCommonParameters().extendable());
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(share);
byte[] secret = recoveryState.recover(getPassphrase());
Assertions.assertEquals("1679b4516e0ee5954351d288a838f45e", HexFormat.of().formatHex(secret));
}
@Test
public void test1of1extendable256() throws MnemonicException {
String mnemonic = "impulse calcium academic academic alcohol sugar lyrics pajamas column facility finance tension extend space birthday rainbow swimming purple syndrome facility trial warn duration snapshot shadow hormone rhyme public spine counter easy hawk album";
Share share = Share.fromMnemonic(mnemonic);
Assertions.assertTrue(share.getCommonParameters().extendable());
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(share);
byte[] secret = recoveryState.recover(getPassphrase());
Assertions.assertEquals("8340611602fe91af634a5f4608377b5235fa2d757c51d720c0c7656249a3035f", HexFormat.of().formatHex(secret));
}
@Test
public void test2of3extendable() throws MnemonicException {
String mnemonic1 = "enemy favorite academic acid cowboy phrase havoc level response walnut budget painting inside trash adjust froth kitchen learn tidy punish";
Share share1 = Share.fromMnemonic(mnemonic1);
Assertions.assertTrue(share1.getCommonParameters().extendable());
String mnemonic2 = "enemy favorite academic always academic sniff script carpet romp kind promise scatter center unfair training emphasis evening belong fake enforce";
Share share2 = Share.fromMnemonic(mnemonic2);
Assertions.assertTrue(share2.getCommonParameters().extendable());
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(share1);
recoveryState.addShare(share2);
byte[] secret = recoveryState.recover(getPassphrase());
Assertions.assertEquals("48b1a4b80b8c209ad42c33672bdaa428", HexFormat.of().formatHex(secret));
}
@Test
public void test2of3extendable256() throws MnemonicException {
String mnemonic1 = "western apart academic always artist resident briefing sugar woman oven coding club ajar merit pecan answer prisoner artist fraction amount desktop mild false necklace muscle photo wealthy alpha category unwrap spew losing making";
Share share1 = Share.fromMnemonic(mnemonic1);
Assertions.assertTrue(share1.getCommonParameters().extendable());
String mnemonic2 = "western apart academic acid answer ancient auction flip image penalty oasis beaver multiple thunder problem switch alive heat inherit superior teaspoon explain blanket pencil numb lend punish endless aunt garlic humidity kidney observe";
Share share2 = Share.fromMnemonic(mnemonic2);
Assertions.assertTrue(share2.getCommonParameters().extendable());
RecoveryState recoveryState = new RecoveryState();
recoveryState.addShare(share1);
recoveryState.addShare(share2);
byte[] secret = recoveryState.recover(getPassphrase());
Assertions.assertEquals("8dc652d6d6cd370d8c963141f6d79ba440300f25c467302c1d966bff8f62300d", HexFormat.of().formatHex(secret));
}
}