Skip to content

Commit f19ebfc

Browse files
authored
Merge pull request #3 from xt0x/feat/bit-and-crypto-primitives
Implement BitPacker class and add PBKDF2-HMAC-SHA512 digest
2 parents fae442e + 62ad89e commit f19ebfc

6 files changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.example.bip39.bit;
2+
3+
import java.util.Objects;
4+
5+
public final class BitPacker {
6+
7+
private static final int ELEVEN_BIT_GROUP_SIZE = 11;
8+
9+
private BitPacker() {}
10+
11+
public static int readBits(byte[] source, int bitOffset, int bitLength) {
12+
Objects.requireNonNull(source, "source must not be null");
13+
validateBitAccess(source.length, bitOffset, bitLength);
14+
if (bitLength > Integer.SIZE - 1) {
15+
throw new IllegalArgumentException("bitLength must be at most 31");
16+
}
17+
18+
int value = 0;
19+
for (int bitIndex = 0; bitIndex < bitLength; bitIndex++) {
20+
int absoluteBitIndex = bitOffset + bitIndex;
21+
int byteIndex = absoluteBitIndex / Byte.SIZE;
22+
int bitIndexInByte = 7 - (absoluteBitIndex % Byte.SIZE);
23+
int bit = ((source[byteIndex] & 0xff) >> bitIndexInByte) & 0x01;
24+
value = (value << 1) | bit;
25+
}
26+
return value;
27+
}
28+
29+
public static void writeBits(byte[] target, int bitOffset, int bitLength, int value) {
30+
Objects.requireNonNull(target, "target must not be null");
31+
validateBitAccess(target.length, bitOffset, bitLength);
32+
if (bitLength > Integer.SIZE - 1) {
33+
throw new IllegalArgumentException("bitLength must be at most 31");
34+
}
35+
if (bitLength != 0 && value >>> bitLength != 0) {
36+
throw new IllegalArgumentException("value does not fit into the requested bit length");
37+
}
38+
39+
for (int bitIndex = 0; bitIndex < bitLength; bitIndex++) {
40+
int absoluteBitIndex = bitOffset + bitIndex;
41+
int byteIndex = absoluteBitIndex / Byte.SIZE;
42+
int bitIndexInByte = 7 - (absoluteBitIndex % Byte.SIZE);
43+
int bitMask = 1 << bitIndexInByte;
44+
int bit = (value >> (bitLength - 1 - bitIndex)) & 0x01;
45+
int clearedByte = target[byteIndex] & ~bitMask;
46+
target[byteIndex] = (byte) (clearedByte | (bit << bitIndexInByte));
47+
}
48+
}
49+
50+
public static int[] splitTo11BitValues(byte[] source, int totalBitLength) {
51+
Objects.requireNonNull(source, "source must not be null");
52+
validateTotalBitLength(source.length, totalBitLength);
53+
if (totalBitLength % ELEVEN_BIT_GROUP_SIZE != 0) {
54+
throw new IllegalArgumentException("totalBitLength must be divisible by 11");
55+
}
56+
57+
int[] values = new int[totalBitLength / ELEVEN_BIT_GROUP_SIZE];
58+
for (int valueIndex = 0; valueIndex < values.length; valueIndex++) {
59+
values[valueIndex] =
60+
readBits(source, valueIndex * ELEVEN_BIT_GROUP_SIZE, ELEVEN_BIT_GROUP_SIZE);
61+
}
62+
return values;
63+
}
64+
65+
public static byte[] pack11BitValues(int[] values) {
66+
Objects.requireNonNull(values, "values must not be null");
67+
byte[] packed = new byte[(values.length * ELEVEN_BIT_GROUP_SIZE + 7) / Byte.SIZE];
68+
for (int valueIndex = 0; valueIndex < values.length; valueIndex++) {
69+
int value = values[valueIndex];
70+
if (value < 0 || value > 0x7ff) {
71+
throw new IllegalArgumentException("11-bit value out of range: " + value);
72+
}
73+
writeBits(packed, valueIndex * ELEVEN_BIT_GROUP_SIZE, ELEVEN_BIT_GROUP_SIZE, value);
74+
}
75+
return packed;
76+
}
77+
78+
public static byte[] extractBytes(byte[] source, int bitOffset, int bitLength) {
79+
Objects.requireNonNull(source, "source must not be null");
80+
validateBitAccess(source.length, bitOffset, bitLength);
81+
if (bitLength % Byte.SIZE != 0) {
82+
throw new IllegalArgumentException("bitLength must be divisible by 8");
83+
}
84+
85+
byte[] bytes = new byte[bitLength / Byte.SIZE];
86+
for (int byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
87+
bytes[byteIndex] = (byte) readBits(source, bitOffset + byteIndex * Byte.SIZE, Byte.SIZE);
88+
}
89+
return bytes;
90+
}
91+
92+
private static void validateTotalBitLength(int sourceLengthBytes, int totalBitLength) {
93+
validateBitAccess(sourceLengthBytes, 0, totalBitLength);
94+
}
95+
96+
private static void validateBitAccess(int sourceLengthBytes, int bitOffset, int bitLength) {
97+
if (bitOffset < 0) {
98+
throw new IllegalArgumentException("bitOffset must not be negative");
99+
}
100+
if (bitLength < 0) {
101+
throw new IllegalArgumentException("bitLength must not be negative");
102+
}
103+
if (bitOffset + bitLength > sourceLengthBytes * Byte.SIZE) {
104+
throw new IllegalArgumentException("Requested bits exceed source length");
105+
}
106+
}
107+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.example.bip39.crypto;
2+
3+
import com.example.bip39.error.Bip39ErrorCode;
4+
import com.example.bip39.error.Bip39Exception;
5+
import com.example.bip39.util.Bip39Constants;
6+
import java.security.InvalidKeyException;
7+
import java.security.NoSuchAlgorithmException;
8+
import java.util.Arrays;
9+
import java.util.Objects;
10+
import javax.crypto.Mac;
11+
import javax.crypto.spec.SecretKeySpec;
12+
13+
public final class Pbkdf2HmacSha512 {
14+
15+
private static final String HMAC_SHA512 = "HmacSHA512";
16+
17+
private Pbkdf2HmacSha512() {}
18+
19+
public static byte[] derive(byte[] passwordBytes, byte[] saltBytes) {
20+
Objects.requireNonNull(passwordBytes, "passwordBytes must not be null");
21+
Objects.requireNonNull(saltBytes, "saltBytes must not be null");
22+
23+
try {
24+
Mac mac = Mac.getInstance(HMAC_SHA512);
25+
mac.init(new SecretKeySpec(passwordBytes, HMAC_SHA512));
26+
return deriveKey(
27+
mac, saltBytes, Bip39Constants.PBKDF2_ITERATIONS, Bip39Constants.SEED_LEN_BYTES);
28+
} catch (NoSuchAlgorithmException | InvalidKeyException exception) {
29+
throw new Bip39Exception(
30+
Bip39ErrorCode.ERR_PBKDF2_FAILURE, "PBKDF2-HMAC-SHA512 is unavailable", exception);
31+
}
32+
}
33+
34+
private static byte[] deriveKey(
35+
Mac mac, byte[] saltBytes, int iterations, int derivedKeyLengthBytes) {
36+
int blockLengthBytes = mac.getMacLength();
37+
int blockCount = (derivedKeyLengthBytes + blockLengthBytes - 1) / blockLengthBytes;
38+
byte[] derivedKey = new byte[blockCount * blockLengthBytes];
39+
40+
for (int blockIndex = 1; blockIndex <= blockCount; blockIndex++) {
41+
byte[] block = deriveBlock(mac, saltBytes, iterations, blockIndex);
42+
System.arraycopy(block, 0, derivedKey, (blockIndex - 1) * blockLengthBytes, block.length);
43+
}
44+
45+
return Arrays.copyOf(derivedKey, derivedKeyLengthBytes);
46+
}
47+
48+
private static byte[] deriveBlock(Mac mac, byte[] saltBytes, int iterations, int blockIndex) {
49+
byte[] input = Arrays.copyOf(saltBytes, saltBytes.length + Integer.BYTES);
50+
writeIntBigEndian(input, saltBytes.length, blockIndex);
51+
52+
byte[] iterationResult = mac.doFinal(input);
53+
byte[] block = iterationResult.clone();
54+
55+
for (int iteration = 1; iteration < iterations; iteration++) {
56+
iterationResult = mac.doFinal(iterationResult);
57+
xorInto(block, iterationResult);
58+
}
59+
60+
return block;
61+
}
62+
63+
private static void xorInto(byte[] target, byte[] source) {
64+
for (int index = 0; index < target.length; index++) {
65+
target[index] ^= source[index];
66+
}
67+
}
68+
69+
private static void writeIntBigEndian(byte[] bytes, int offset, int value) {
70+
bytes[offset] = (byte) (value >>> 24);
71+
bytes[offset + 1] = (byte) (value >>> 16);
72+
bytes[offset + 2] = (byte) (value >>> 8);
73+
bytes[offset + 3] = (byte) value;
74+
}
75+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.example.bip39.crypto;
2+
3+
import java.security.MessageDigest;
4+
import java.security.NoSuchAlgorithmException;
5+
import java.util.Objects;
6+
7+
public final class Sha256Digest {
8+
9+
private Sha256Digest() {}
10+
11+
public static byte[] digest(byte[] input) {
12+
Objects.requireNonNull(input, "input must not be null");
13+
14+
try {
15+
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
16+
return messageDigest.digest(input);
17+
} catch (NoSuchAlgorithmException exception) {
18+
throw new IllegalStateException("SHA-256 is unavailable", exception);
19+
}
20+
}
21+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.example.bip39.bit;
2+
3+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
7+
import org.junit.jupiter.api.Test;
8+
9+
class BitPackerTest {
10+
11+
@Test
12+
void readsBitsMostSignificantBitFirstFromSignedBytes() {
13+
byte[] source = {(byte) 0x80, (byte) 0xf1};
14+
15+
assertEquals(1, BitPacker.readBits(source, 0, 1));
16+
assertEquals(0, BitPacker.readBits(source, 1, 7));
17+
assertEquals(0xf1, BitPacker.readBits(source, 8, 8));
18+
assertEquals(0x01, BitPacker.readBits(source, 12, 4));
19+
}
20+
21+
@Test
22+
void writesBitsAndExtractsBytesWithoutLosingUnsignedValues() {
23+
byte[] target = new byte[3];
24+
25+
BitPacker.writeBits(target, 0, 8, 0x80);
26+
BitPacker.writeBits(target, 8, 8, 0x01);
27+
BitPacker.writeBits(target, 16, 8, 0xff);
28+
29+
assertArrayEquals(new byte[] {(byte) 0x80, 0x01, (byte) 0xff}, target);
30+
assertArrayEquals(target, BitPacker.extractBytes(target, 0, 24));
31+
}
32+
33+
@Test
34+
void extractsBytesAcrossByteBoundaries() {
35+
byte[] source = {0x12, 0x34, 0x56};
36+
37+
assertArrayEquals(new byte[] {0x23, 0x45}, BitPacker.extractBytes(source, 4, 16));
38+
}
39+
40+
@Test
41+
void splitsKnownBitSequenceIntoElevenBitValues() {
42+
byte[] source = {0x12, 0x34, 0x56};
43+
44+
assertArrayEquals(new int[] {145, 1301}, BitPacker.splitTo11BitValues(source, 22));
45+
}
46+
47+
@Test
48+
void packsElevenBitValuesInMostSignificantBitOrder() {
49+
assertArrayEquals(
50+
new byte[] {0x12, 0x34, 0x54}, BitPacker.pack11BitValues(new int[] {145, 1301}));
51+
}
52+
53+
@Test
54+
void elevenBitPackingAndSplittingRoundTrips() {
55+
int[] values = {0, 1, 1024, 2047, 42};
56+
57+
byte[] packed = BitPacker.pack11BitValues(values);
58+
59+
assertArrayEquals(values, BitPacker.splitTo11BitValues(packed, values.length * 11));
60+
}
61+
62+
@Test
63+
void rejectsInvalidBitAccessAndOutOfRangeValues() {
64+
byte[] source = new byte[2];
65+
66+
assertThrows(IllegalArgumentException.class, () -> BitPacker.readBits(source, -1, 1));
67+
assertThrows(IllegalArgumentException.class, () -> BitPacker.readBits(source, 0, 17));
68+
assertThrows(IllegalArgumentException.class, () -> BitPacker.writeBits(source, 0, 3, 0b1000));
69+
assertThrows(
70+
IllegalArgumentException.class, () -> BitPacker.splitTo11BitValues(source, Byte.SIZE));
71+
assertThrows(IllegalArgumentException.class, () -> BitPacker.pack11BitValues(new int[] {2048}));
72+
assertThrows(IllegalArgumentException.class, () -> BitPacker.extractBytes(source, 0, 7));
73+
}
74+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.example.bip39.crypto;
2+
3+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
7+
import com.example.bip39.util.Bip39Constants;
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.HexFormat;
10+
import org.junit.jupiter.api.Test;
11+
12+
class Pbkdf2HmacSha512Test {
13+
14+
@Test
15+
void derivesBip39SeedForFirstOfficialVector() {
16+
byte[] derived =
17+
Pbkdf2HmacSha512.derive(
18+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
19+
.getBytes(StandardCharsets.UTF_8),
20+
"mnemonicTREZOR".getBytes(StandardCharsets.UTF_8));
21+
22+
assertEquals(Bip39Constants.SEED_LEN_BYTES, derived.length);
23+
assertEquals(
24+
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553"
25+
+ "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
26+
HexFormat.of().formatHex(derived));
27+
}
28+
29+
@Test
30+
void isDeterministicForSameInputs() {
31+
byte[] passwordBytes =
32+
"legal winner thank year wave sausage worth useful legal winner thank yellow"
33+
.getBytes(StandardCharsets.UTF_8);
34+
byte[] saltBytes = "mnemonicTREZOR".getBytes(StandardCharsets.UTF_8);
35+
36+
assertArrayEquals(
37+
Pbkdf2HmacSha512.derive(passwordBytes, saltBytes),
38+
Pbkdf2HmacSha512.derive(passwordBytes, saltBytes));
39+
}
40+
41+
@Test
42+
void rejectsNullInputs() {
43+
byte[] passwordBytes = "password".getBytes(StandardCharsets.UTF_8);
44+
byte[] saltBytes = "salt".getBytes(StandardCharsets.UTF_8);
45+
46+
assertThrows(NullPointerException.class, () -> Pbkdf2HmacSha512.derive(null, saltBytes));
47+
assertThrows(NullPointerException.class, () -> Pbkdf2HmacSha512.derive(passwordBytes, null));
48+
}
49+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.example.bip39.crypto;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertThrows;
5+
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.HexFormat;
8+
import org.junit.jupiter.api.Test;
9+
10+
class Sha256DigestTest {
11+
12+
@Test
13+
void hashesKnownInput() {
14+
byte[] digest = Sha256Digest.digest("abc".getBytes(StandardCharsets.UTF_8));
15+
16+
assertEquals(
17+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
18+
HexFormat.of().formatHex(digest));
19+
}
20+
21+
@Test
22+
void rejectsNullInput() {
23+
assertThrows(NullPointerException.class, () -> Sha256Digest.digest(null));
24+
}
25+
}

0 commit comments

Comments
 (0)