diff --git a/src/main/java/com/example/bip39/api/DefaultBip39Service.java b/src/main/java/com/example/bip39/api/DefaultBip39Service.java index d2404ce..b9deb13 100644 --- a/src/main/java/com/example/bip39/api/DefaultBip39Service.java +++ b/src/main/java/com/example/bip39/api/DefaultBip39Service.java @@ -1,11 +1,15 @@ package com.example.bip39.api; +import com.example.bip39.bit.BitPacker; +import com.example.bip39.crypto.Sha256Digest; import com.example.bip39.error.Bip39ErrorCode; import com.example.bip39.error.Bip39Exception; import com.example.bip39.model.ValidationResult; import com.example.bip39.parser.StrictMnemonicParser; +import com.example.bip39.util.Bip39Constants; import com.example.bip39.wordlist.Bip39WordList; import com.example.bip39.wordlist.EnglishWordList; +import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -27,7 +31,18 @@ Bip39WordList wordList() { @Override public String entropyToMnemonic(byte[] entropy) { - throw new UnsupportedOperationException("entropyToMnemonic is not implemented yet"); + validateEntropy(entropy); + + int checksumLengthBits = Bip39Constants.checksumLengthBits(entropy.length); + int entropyLengthBits = entropy.length * Byte.SIZE; + int totalBitLength = entropyLengthBits + checksumLengthBits; + + byte[] entropyWithChecksum = Arrays.copyOf(entropy, (totalBitLength + 7) / Byte.SIZE); + int checksumBits = BitPacker.readBits(Sha256Digest.digest(entropy), 0, checksumLengthBits); + BitPacker.writeBits(entropyWithChecksum, entropyLengthBits, checksumLengthBits, checksumBits); + + int[] wordIndexes = BitPacker.splitTo11BitValues(entropyWithChecksum, totalBitLength); + return joinMnemonicWords(wordIndexes); } @Override @@ -78,4 +93,21 @@ private static ValidationResult invalidFormatResult(Bip39Exception exception) { } return ValidationResult.failure(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, null, null, null); } + + private static void validateEntropy(byte[] entropy) { + if (entropy == null || !Bip39Constants.isAllowedEntropyLengthBytes(entropy.length)) { + throw new Bip39Exception(Bip39ErrorCode.ERR_ENTROPY_LENGTH, "Unsupported entropy length"); + } + } + + private String joinMnemonicWords(int[] wordIndexes) { + StringBuilder mnemonic = new StringBuilder(); + for (int index = 0; index < wordIndexes.length; index++) { + if (index > 0) { + mnemonic.append(' '); + } + mnemonic.append(wordList.indexToWord(wordIndexes[index])); + } + return mnemonic.toString(); + } } diff --git a/src/main/java/com/example/bip39/bit/BitPacker.java b/src/main/java/com/example/bip39/bit/BitPacker.java new file mode 100644 index 0000000..0b3ca9b --- /dev/null +++ b/src/main/java/com/example/bip39/bit/BitPacker.java @@ -0,0 +1,107 @@ +package com.example.bip39.bit; + +import java.util.Objects; + +public final class BitPacker { + + private static final int ELEVEN_BIT_GROUP_SIZE = 11; + + private BitPacker() {} + + public static int readBits(byte[] source, int bitOffset, int bitLength) { + Objects.requireNonNull(source, "source must not be null"); + validateBitAccess(source.length, bitOffset, bitLength); + if (bitLength > Integer.SIZE - 1) { + throw new IllegalArgumentException("bitLength must be at most 31"); + } + + int value = 0; + for (int bitIndex = 0; bitIndex < bitLength; bitIndex++) { + int absoluteBitIndex = bitOffset + bitIndex; + int byteIndex = absoluteBitIndex / Byte.SIZE; + int bitIndexInByte = 7 - (absoluteBitIndex % Byte.SIZE); + int bit = ((source[byteIndex] & 0xff) >> bitIndexInByte) & 0x01; + value = (value << 1) | bit; + } + return value; + } + + public static void writeBits(byte[] target, int bitOffset, int bitLength, int value) { + Objects.requireNonNull(target, "target must not be null"); + validateBitAccess(target.length, bitOffset, bitLength); + if (bitLength > Integer.SIZE - 1) { + throw new IllegalArgumentException("bitLength must be at most 31"); + } + if (bitLength != 0 && value >>> bitLength != 0) { + throw new IllegalArgumentException("value does not fit into the requested bit length"); + } + + for (int bitIndex = 0; bitIndex < bitLength; bitIndex++) { + int absoluteBitIndex = bitOffset + bitIndex; + int byteIndex = absoluteBitIndex / Byte.SIZE; + int bitIndexInByte = 7 - (absoluteBitIndex % Byte.SIZE); + int bitMask = 1 << bitIndexInByte; + int bit = (value >> (bitLength - 1 - bitIndex)) & 0x01; + int clearedByte = target[byteIndex] & ~bitMask; + target[byteIndex] = (byte) (clearedByte | (bit << bitIndexInByte)); + } + } + + public static int[] splitTo11BitValues(byte[] source, int totalBitLength) { + Objects.requireNonNull(source, "source must not be null"); + validateTotalBitLength(source.length, totalBitLength); + if (totalBitLength % ELEVEN_BIT_GROUP_SIZE != 0) { + throw new IllegalArgumentException("totalBitLength must be divisible by 11"); + } + + int[] values = new int[totalBitLength / ELEVEN_BIT_GROUP_SIZE]; + for (int valueIndex = 0; valueIndex < values.length; valueIndex++) { + values[valueIndex] = + readBits(source, valueIndex * ELEVEN_BIT_GROUP_SIZE, ELEVEN_BIT_GROUP_SIZE); + } + return values; + } + + public static byte[] pack11BitValues(int[] values) { + Objects.requireNonNull(values, "values must not be null"); + byte[] packed = new byte[(values.length * ELEVEN_BIT_GROUP_SIZE + 7) / Byte.SIZE]; + for (int valueIndex = 0; valueIndex < values.length; valueIndex++) { + int value = values[valueIndex]; + if (value < 0 || value > 0x7ff) { + throw new IllegalArgumentException("11-bit value out of range: " + value); + } + writeBits(packed, valueIndex * ELEVEN_BIT_GROUP_SIZE, ELEVEN_BIT_GROUP_SIZE, value); + } + return packed; + } + + public static byte[] extractBytes(byte[] source, int bitOffset, int bitLength) { + Objects.requireNonNull(source, "source must not be null"); + validateBitAccess(source.length, bitOffset, bitLength); + if (bitLength % Byte.SIZE != 0) { + throw new IllegalArgumentException("bitLength must be divisible by 8"); + } + + byte[] bytes = new byte[bitLength / Byte.SIZE]; + for (int byteIndex = 0; byteIndex < bytes.length; byteIndex++) { + bytes[byteIndex] = (byte) readBits(source, bitOffset + byteIndex * Byte.SIZE, Byte.SIZE); + } + return bytes; + } + + private static void validateTotalBitLength(int sourceLengthBytes, int totalBitLength) { + validateBitAccess(sourceLengthBytes, 0, totalBitLength); + } + + private static void validateBitAccess(int sourceLengthBytes, int bitOffset, int bitLength) { + if (bitOffset < 0) { + throw new IllegalArgumentException("bitOffset must not be negative"); + } + if (bitLength < 0) { + throw new IllegalArgumentException("bitLength must not be negative"); + } + if (bitOffset + bitLength > sourceLengthBytes * Byte.SIZE) { + throw new IllegalArgumentException("Requested bits exceed source length"); + } + } +} diff --git a/src/main/java/com/example/bip39/crypto/Pbkdf2HmacSha512.java b/src/main/java/com/example/bip39/crypto/Pbkdf2HmacSha512.java new file mode 100644 index 0000000..bc88845 --- /dev/null +++ b/src/main/java/com/example/bip39/crypto/Pbkdf2HmacSha512.java @@ -0,0 +1,75 @@ +package com.example.bip39.crypto; + +import com.example.bip39.error.Bip39ErrorCode; +import com.example.bip39.error.Bip39Exception; +import com.example.bip39.util.Bip39Constants; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public final class Pbkdf2HmacSha512 { + + private static final String HMAC_SHA512 = "HmacSHA512"; + + private Pbkdf2HmacSha512() {} + + public static byte[] derive(byte[] passwordBytes, byte[] saltBytes) { + Objects.requireNonNull(passwordBytes, "passwordBytes must not be null"); + Objects.requireNonNull(saltBytes, "saltBytes must not be null"); + + try { + Mac mac = Mac.getInstance(HMAC_SHA512); + mac.init(new SecretKeySpec(passwordBytes, HMAC_SHA512)); + return deriveKey( + mac, saltBytes, Bip39Constants.PBKDF2_ITERATIONS, Bip39Constants.SEED_LEN_BYTES); + } catch (NoSuchAlgorithmException | InvalidKeyException exception) { + throw new Bip39Exception( + Bip39ErrorCode.ERR_PBKDF2_FAILURE, "PBKDF2-HMAC-SHA512 is unavailable", exception); + } + } + + private static byte[] deriveKey( + Mac mac, byte[] saltBytes, int iterations, int derivedKeyLengthBytes) { + int blockLengthBytes = mac.getMacLength(); + int blockCount = (derivedKeyLengthBytes + blockLengthBytes - 1) / blockLengthBytes; + byte[] derivedKey = new byte[blockCount * blockLengthBytes]; + + for (int blockIndex = 1; blockIndex <= blockCount; blockIndex++) { + byte[] block = deriveBlock(mac, saltBytes, iterations, blockIndex); + System.arraycopy(block, 0, derivedKey, (blockIndex - 1) * blockLengthBytes, block.length); + } + + return Arrays.copyOf(derivedKey, derivedKeyLengthBytes); + } + + private static byte[] deriveBlock(Mac mac, byte[] saltBytes, int iterations, int blockIndex) { + byte[] input = Arrays.copyOf(saltBytes, saltBytes.length + Integer.BYTES); + writeIntBigEndian(input, saltBytes.length, blockIndex); + + byte[] iterationResult = mac.doFinal(input); + byte[] block = iterationResult.clone(); + + for (int iteration = 1; iteration < iterations; iteration++) { + iterationResult = mac.doFinal(iterationResult); + xorInto(block, iterationResult); + } + + return block; + } + + private static void xorInto(byte[] target, byte[] source) { + for (int index = 0; index < target.length; index++) { + target[index] ^= source[index]; + } + } + + private static void writeIntBigEndian(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) (value >>> 24); + bytes[offset + 1] = (byte) (value >>> 16); + bytes[offset + 2] = (byte) (value >>> 8); + bytes[offset + 3] = (byte) value; + } +} diff --git a/src/main/java/com/example/bip39/crypto/Sha256Digest.java b/src/main/java/com/example/bip39/crypto/Sha256Digest.java new file mode 100644 index 0000000..be16a99 --- /dev/null +++ b/src/main/java/com/example/bip39/crypto/Sha256Digest.java @@ -0,0 +1,21 @@ +package com.example.bip39.crypto; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +public final class Sha256Digest { + + private Sha256Digest() {} + + public static byte[] digest(byte[] input) { + Objects.requireNonNull(input, "input must not be null"); + + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + return messageDigest.digest(input); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java index 4527a2c..a095138 100644 --- a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java +++ b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java @@ -31,11 +31,12 @@ void servicesShareSingleLoadedWordListInstance() { } @Test - void methodsBeyondParsingRemainExplicitlyUnimplementedForNormalizedInputs() { + void methodsBeyondEntropyEncodingRemainExplicitlyUnimplementedForNormalizedInputs() { DefaultBip39Service service = new DefaultBip39Service(); - assertThrows( - UnsupportedOperationException.class, () -> service.entropyToMnemonic(new byte[16])); + assertEquals( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + service.entropyToMnemonic(new byte[16])); assertThrows( UnsupportedOperationException.class, () -> service.mnemonicToEntropy("abandon ability able")); diff --git a/src/test/java/com/example/bip39/api/EntropyToMnemonicTest.java b/src/test/java/com/example/bip39/api/EntropyToMnemonicTest.java new file mode 100644 index 0000000..4432540 --- /dev/null +++ b/src/test/java/com/example/bip39/api/EntropyToMnemonicTest.java @@ -0,0 +1,58 @@ +package com.example.bip39.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.example.bip39.error.Bip39ErrorCode; +import com.example.bip39.error.Bip39Exception; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; + +class EntropyToMnemonicTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + void matchesAllOfficialEnglishEntropyToMnemonicVectors() throws IOException { + DefaultBip39Service service = new DefaultBip39Service(); + JsonNode vectors = loadEnglishVectors(); + + for (JsonNode vector : vectors) { + byte[] entropy = HexFormat.of().parseHex(vector.get(0).textValue()); + String expectedMnemonic = vector.get(1).textValue(); + + assertEquals(expectedMnemonic, service.entropyToMnemonic(entropy)); + } + } + + @Test + void rejectsUnsupportedEntropyLengths() { + DefaultBip39Service service = new DefaultBip39Service(); + + Bip39Exception nullException = + assertThrows(Bip39Exception.class, () -> service.entropyToMnemonic(null)); + assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, nullException.getErrorCode()); + + Bip39Exception shortException = + assertThrows(Bip39Exception.class, () -> service.entropyToMnemonic(new byte[12])); + assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, shortException.getErrorCode()); + + Bip39Exception longException = + assertThrows(Bip39Exception.class, () -> service.entropyToMnemonic(new byte[33])); + assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, longException.getErrorCode()); + } + + private static JsonNode loadEnglishVectors() throws IOException { + try (InputStream inputStream = + EntropyToMnemonicTest.class.getResourceAsStream("/bip39/vectors.json")) { + if (inputStream == null) { + throw new IllegalStateException("Missing resource: /bip39/vectors.json"); + } + return OBJECT_MAPPER.readTree(inputStream).get("english"); + } + } +} diff --git a/src/test/java/com/example/bip39/bit/BitPackerTest.java b/src/test/java/com/example/bip39/bit/BitPackerTest.java new file mode 100644 index 0000000..1829f32 --- /dev/null +++ b/src/test/java/com/example/bip39/bit/BitPackerTest.java @@ -0,0 +1,74 @@ +package com.example.bip39.bit; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class BitPackerTest { + + @Test + void readsBitsMostSignificantBitFirstFromSignedBytes() { + byte[] source = {(byte) 0x80, (byte) 0xf1}; + + assertEquals(1, BitPacker.readBits(source, 0, 1)); + assertEquals(0, BitPacker.readBits(source, 1, 7)); + assertEquals(0xf1, BitPacker.readBits(source, 8, 8)); + assertEquals(0x01, BitPacker.readBits(source, 12, 4)); + } + + @Test + void writesBitsAndExtractsBytesWithoutLosingUnsignedValues() { + byte[] target = new byte[3]; + + BitPacker.writeBits(target, 0, 8, 0x80); + BitPacker.writeBits(target, 8, 8, 0x01); + BitPacker.writeBits(target, 16, 8, 0xff); + + assertArrayEquals(new byte[] {(byte) 0x80, 0x01, (byte) 0xff}, target); + assertArrayEquals(target, BitPacker.extractBytes(target, 0, 24)); + } + + @Test + void extractsBytesAcrossByteBoundaries() { + byte[] source = {0x12, 0x34, 0x56}; + + assertArrayEquals(new byte[] {0x23, 0x45}, BitPacker.extractBytes(source, 4, 16)); + } + + @Test + void splitsKnownBitSequenceIntoElevenBitValues() { + byte[] source = {0x12, 0x34, 0x56}; + + assertArrayEquals(new int[] {145, 1301}, BitPacker.splitTo11BitValues(source, 22)); + } + + @Test + void packsElevenBitValuesInMostSignificantBitOrder() { + assertArrayEquals( + new byte[] {0x12, 0x34, 0x54}, BitPacker.pack11BitValues(new int[] {145, 1301})); + } + + @Test + void elevenBitPackingAndSplittingRoundTrips() { + int[] values = {0, 1, 1024, 2047, 42}; + + byte[] packed = BitPacker.pack11BitValues(values); + + assertArrayEquals(values, BitPacker.splitTo11BitValues(packed, values.length * 11)); + } + + @Test + void rejectsInvalidBitAccessAndOutOfRangeValues() { + byte[] source = new byte[2]; + + assertThrows(IllegalArgumentException.class, () -> BitPacker.readBits(source, -1, 1)); + assertThrows(IllegalArgumentException.class, () -> BitPacker.readBits(source, 0, 17)); + assertThrows(IllegalArgumentException.class, () -> BitPacker.writeBits(source, 0, 3, 0b1000)); + assertThrows( + IllegalArgumentException.class, () -> BitPacker.splitTo11BitValues(source, Byte.SIZE)); + assertThrows(IllegalArgumentException.class, () -> BitPacker.pack11BitValues(new int[] {2048})); + assertThrows(IllegalArgumentException.class, () -> BitPacker.extractBytes(source, 0, 7)); + } +} diff --git a/src/test/java/com/example/bip39/crypto/Pbkdf2HmacSha512Test.java b/src/test/java/com/example/bip39/crypto/Pbkdf2HmacSha512Test.java new file mode 100644 index 0000000..cffb55d --- /dev/null +++ b/src/test/java/com/example/bip39/crypto/Pbkdf2HmacSha512Test.java @@ -0,0 +1,49 @@ +package com.example.bip39.crypto; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.example.bip39.util.Bip39Constants; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; + +class Pbkdf2HmacSha512Test { + + @Test + void derivesBip39SeedForFirstOfficialVector() { + byte[] derived = + Pbkdf2HmacSha512.derive( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + .getBytes(StandardCharsets.UTF_8), + "mnemonicTREZOR".getBytes(StandardCharsets.UTF_8)); + + assertEquals(Bip39Constants.SEED_LEN_BYTES, derived.length); + assertEquals( + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", + HexFormat.of().formatHex(derived)); + } + + @Test + void isDeterministicForSameInputs() { + byte[] passwordBytes = + "legal winner thank year wave sausage worth useful legal winner thank yellow" + .getBytes(StandardCharsets.UTF_8); + byte[] saltBytes = "mnemonicTREZOR".getBytes(StandardCharsets.UTF_8); + + assertArrayEquals( + Pbkdf2HmacSha512.derive(passwordBytes, saltBytes), + Pbkdf2HmacSha512.derive(passwordBytes, saltBytes)); + } + + @Test + void rejectsNullInputs() { + byte[] passwordBytes = "password".getBytes(StandardCharsets.UTF_8); + byte[] saltBytes = "salt".getBytes(StandardCharsets.UTF_8); + + assertThrows(NullPointerException.class, () -> Pbkdf2HmacSha512.derive(null, saltBytes)); + assertThrows(NullPointerException.class, () -> Pbkdf2HmacSha512.derive(passwordBytes, null)); + } +} diff --git a/src/test/java/com/example/bip39/crypto/Sha256DigestTest.java b/src/test/java/com/example/bip39/crypto/Sha256DigestTest.java new file mode 100644 index 0000000..5294989 --- /dev/null +++ b/src/test/java/com/example/bip39/crypto/Sha256DigestTest.java @@ -0,0 +1,25 @@ +package com.example.bip39.crypto; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; + +class Sha256DigestTest { + + @Test + void hashesKnownInput() { + byte[] digest = Sha256Digest.digest("abc".getBytes(StandardCharsets.UTF_8)); + + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + HexFormat.of().formatHex(digest)); + } + + @Test + void rejectsNullInput() { + assertThrows(NullPointerException.class, () -> Sha256Digest.digest(null)); + } +}