Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# HAP-Java 2.0.8
* Updated bouncy castle to 1.82

# HAP-Java 2.0.7
* Add overloads to characteristics so that the username can be passed through.

Expand Down
10 changes: 8 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,16 @@

<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.51</version>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.82</version>
</dependency>

<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bctls-jdk18on</artifactId>
<version>1.82</version>
</dependency>

<dependency>
<groupId>net.vrallev.ecc</groupId>
<artifactId>ecc-25519-java</artifactId>
Expand Down
98 changes: 71 additions & 27 deletions src/main/java/io/github/hapjava/server/impl/crypto/ChachaDecoder.java
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,54 +1,98 @@
package io.github.hapjava.server.impl.crypto;

import java.io.IOException;
import org.bouncycastle.crypto.engines.ChaChaEngine;
import org.bouncycastle.crypto.generators.Poly1305KeyGenerator;
import org.bouncycastle.crypto.InvalidCipherTextException;
import org.bouncycastle.crypto.modes.ChaCha20Poly1305;
import org.bouncycastle.crypto.params.AEADParameters;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.crypto.params.ParametersWithIV;
import org.bouncycastle.crypto.tls.AlertDescription;
import org.bouncycastle.crypto.tls.TlsFatalAlert;
import org.bouncycastle.util.Arrays;
import org.bouncycastle.tls.AlertDescription;
import org.bouncycastle.tls.TlsFatalAlert;

public class ChachaDecoder {

private final ChaChaEngine decryptCipher;
private final ChaCha20Poly1305 cipher;
private final byte[] key;
private final byte[] nonce;

public ChachaDecoder(byte[] key, byte[] nonce) throws IOException {
this.key = key;
// ChaCha20-Poly1305 requires exactly 12 bytes (96 bits) for nonce
this.nonce = ensureNonceSize(nonce);
this.cipher = new ChaCha20Poly1305();
}

this.decryptCipher = new ChaChaEngine(20);
private byte[] ensureNonceSize(byte[] nonce) {
if (nonce == null) {
return new byte[12]; // Default to zero nonce if null
}

this.decryptCipher.init(false, new ParametersWithIV(new KeyParameter(key), nonce));
// For HomeKit pairing messages, handle Apple's string-based nonces
if (nonce.length == 8) {
// Apple's HomeKit implementation uses a specific nonce format
// Based on RFC 7539 and Apple's implementation, the nonce should be:
// - 4 bytes constant (0x00000000)
// - 8 bytes nonce string
// This matches ChaCha20's 96-bit nonce requirement
byte[] adjustedNonce = new byte[12];
// Put the 8-byte nonce at the END (bytes 4-11), not at the beginning
System.arraycopy(nonce, 0, adjustedNonce, 4, 8);
// First 4 bytes remain zero (counter initialization)
return adjustedNonce;
}

if (nonce.length == 12) {
return nonce; // Already correct size
}

// For other nonce lengths, pad or truncate to 12 bytes
byte[] adjustedNonce = new byte[12];
if (nonce.length < 12) {
// Pad with zeros if too short - put nonce at beginning
System.arraycopy(nonce, 0, adjustedNonce, 0, nonce.length);
// Remaining bytes are already zero
} else {
// Truncate if too long - take first 12 bytes
System.arraycopy(nonce, 0, adjustedNonce, 0, 12);
}

return adjustedNonce;
}

public byte[] decodeCiphertext(byte[] receivedMAC, byte[] additionalData, byte[] ciphertext)
throws IOException {

KeyParameter macKey = initRecordMAC(decryptCipher);
try {
byte[] ciphertextWithTag = new byte[ciphertext.length + receivedMAC.length];
System.arraycopy(ciphertext, 0, ciphertextWithTag, 0, ciphertext.length);
System.arraycopy(receivedMAC, 0, ciphertextWithTag, ciphertext.length, receivedMAC.length);

ChaCha20Poly1305 cipher1 = new ChaCha20Poly1305();
AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, additionalData);
cipher1.init(false, params);

byte[] calculatedMAC = PolyKeyCreator.create(macKey, additionalData, ciphertext);
byte[] output = new byte[cipher1.getOutputSize(ciphertextWithTag.length)];
int len = cipher1.processBytes(ciphertextWithTag, 0, ciphertextWithTag.length, output, 0);
len += cipher1.doFinal(output, len);

if (!Arrays.constantTimeAreEqual(calculatedMAC, receivedMAC)) {
byte[] result = new byte[len];
System.arraycopy(output, 0, result, 0, len);

return result;

} catch (InvalidCipherTextException e) {
throw new TlsFatalAlert(AlertDescription.bad_record_mac);
}
}

byte[] output = new byte[ciphertext.length];
decryptCipher.processBytes(ciphertext, 0, ciphertext.length, output, 0);

return output;
private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}

public byte[] decodeCiphertext(byte[] receivedMAC, byte[] ciphertext) throws IOException {
return decodeCiphertext(receivedMAC, null, ciphertext);
}

private KeyParameter initRecordMAC(ChaChaEngine cipher) {
byte[] firstBlock = new byte[64];
cipher.processBytes(firstBlock, 0, firstBlock.length, firstBlock, 0);

// NOTE: The BC implementation puts 'r' after 'k'
System.arraycopy(firstBlock, 0, firstBlock, 32, 16);
KeyParameter macKey = new KeyParameter(firstBlock, 16, 32);
Poly1305KeyGenerator.clamp(macKey.getKey());
return macKey;
}
}
95 changes: 70 additions & 25 deletions src/main/java/io/github/hapjava/server/impl/crypto/ChachaEncoder.java
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,48 +1,93 @@
package io.github.hapjava.server.impl.crypto;

import java.io.IOException;
import org.bouncycastle.crypto.engines.ChaChaEngine;
import org.bouncycastle.crypto.generators.Poly1305KeyGenerator;
import org.bouncycastle.crypto.InvalidCipherTextException;
import org.bouncycastle.crypto.modes.ChaCha20Poly1305;
import org.bouncycastle.crypto.params.AEADParameters;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.crypto.params.ParametersWithIV;

public class ChachaEncoder {

private final ChaChaEngine encryptCipher;
private final ChaCha20Poly1305 cipher;
private final byte[] key;
private final byte[] nonce;

public ChachaEncoder(byte[] key, byte[] nonce) throws IOException {
this.key = key;
// ChaCha20-Poly1305 requires exactly 12 bytes (96 bits) for nonce
this.nonce = ensureNonceSize(nonce);
this.cipher = new ChaCha20Poly1305();
}

private byte[] ensureNonceSize(byte[] nonce) {
if (nonce == null) {
return new byte[12]; // Default to zero nonce if null
}

// For HomeKit pairing messages, handle Apple's string-based nonces
if (nonce.length == 8) {
// Apple's HomeKit implementation uses a specific nonce format
// Based on RFC 7539 and Apple's implementation, the nonce should be:
// - 4 bytes constant (0x00000000)
// - 8 bytes nonce string
// This matches ChaCha20's 96-bit nonce requirement and ChachaDecoder format
byte[] adjustedNonce = new byte[12];
// Put the 8-byte nonce at the END (bytes 4-11), not at the beginning
System.arraycopy(nonce, 0, adjustedNonce, 4, 8);
// First 4 bytes remain zero (counter initialization)
return adjustedNonce;
}

if (nonce.length == 12) {
return nonce; // Already correct size
}

this.encryptCipher = new ChaChaEngine(20);
byte[] adjustedNonce = new byte[12];
if (nonce.length < 12) {
// Pad with zeros if too short
System.arraycopy(nonce, 0, adjustedNonce, 0, nonce.length);
} else {
// Truncate if too long
System.arraycopy(nonce, 0, adjustedNonce, 0, 12);
}
return adjustedNonce;
}

this.encryptCipher.init(true, new ParametersWithIV(new KeyParameter(key), nonce));
private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}

public byte[] encodeCiphertext(byte[] plaintext) throws IOException {
return encodeCiphertext(plaintext, null);
}

public byte[] encodeCiphertext(byte[] plaintext, byte[] additionalData) throws IOException {
KeyParameter macKey = initRecordMAC(encryptCipher);

byte[] ciphertext = new byte[plaintext.length];
encryptCipher.processBytes(plaintext, 0, plaintext.length, ciphertext, 0);
try {
// Use the nonce provided during construction
AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, additionalData);
cipher.init(true, params);

byte[] calculatedMAC = PolyKeyCreator.create(macKey, additionalData, ciphertext);

byte[] ret = new byte[ciphertext.length + 16];
System.arraycopy(ciphertext, 0, ret, 0, ciphertext.length);
System.arraycopy(calculatedMAC, 0, ret, ciphertext.length, 16);
return ret;
}
byte[] output = new byte[cipher.getOutputSize(plaintext.length)];
int len = cipher.processBytes(plaintext, 0, plaintext.length, output, 0);
len += cipher.doFinal(output, len);

private KeyParameter initRecordMAC(ChaChaEngine cipher) {
byte[] firstBlock = new byte[64];
cipher.processBytes(firstBlock, 0, firstBlock.length, firstBlock, 0);
// Split the result into ciphertext and MAC
byte[] ciphertext = new byte[plaintext.length];
byte[] mac = new byte[16];
System.arraycopy(output, 0, ciphertext, 0, plaintext.length);
System.arraycopy(output, plaintext.length, mac, 0, 16);

// NOTE: The BC implementation puts 'r' after 'k'
System.arraycopy(firstBlock, 0, firstBlock, 32, 16);
KeyParameter macKey = new KeyParameter(firstBlock, 16, 32);
Poly1305KeyGenerator.clamp(macKey.getKey());
return macKey;
// Return combined ciphertext + MAC as expected by the original interface
byte[] ret = new byte[ciphertext.length + 16];
System.arraycopy(ciphertext, 0, ret, 0, ciphertext.length);
System.arraycopy(mac, 0, ret, ciphertext.length, 16);
return ret;
} catch (InvalidCipherTextException e) {
throw new IOException("Encryption failed", e);
}
}
}
28 changes: 26 additions & 2 deletions src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public ExchangeHandler(byte[] k, HomekitAuthInfo authInfo) {
}

public HttpResponse handle(PairSetupRequest req) throws Exception {
LOGGER.debug("ExchangeHandler: Starting M5 exchange with shared secret K: {}", bytesToHex(k));

HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest());
hkdf.init(
new HKDFParameters(
Expand All @@ -40,12 +42,26 @@ public HttpResponse handle(PairSetupRequest req) throws Exception {
byte[] okm = hkdf_enc_key = new byte[32];
hkdf.generateBytes(okm, 0, 32);

LOGGER.debug("ExchangeHandler: HKDF encryption key: {}", bytesToHex(okm));

return decrypt((ExchangeRequest) req, okm);
}

private HttpResponse decrypt(ExchangeRequest req, byte[] key) throws Exception {
ChachaDecoder chacha = new ChachaDecoder(key, "PS-Msg05".getBytes(StandardCharsets.UTF_8));
byte[] plaintext = chacha.decodeCiphertext(req.getAuthTagData(), req.getMessageData());
LOGGER.debug("ExchangeHandler: Received AuthTag: {}", bytesToHex(req.getAuthTagData()));
LOGGER.debug("ExchangeHandler: Received MessageData: {}", bytesToHex(req.getMessageData()));

try {
ChachaDecoder chacha = new ChachaDecoder(key, "PS-Msg05".getBytes(StandardCharsets.UTF_8));
byte[] plaintext = chacha.decodeCiphertext(req.getAuthTagData(), req.getMessageData());
return processDecryptedData(plaintext);
} catch (Exception e) {
LOGGER.error("ExchangeHandler: M5 decryption failed: {}", e.getMessage());
throw new RuntimeException("HomeKit M5 message decryption failed", e);
}
}

private HttpResponse processDecryptedData(byte[] plaintext) throws Exception {

DecodeResult d = TypeLengthValueUtils.decode(plaintext);
byte[] username = d.getBytes(MessageType.USERNAME);
Expand All @@ -54,6 +70,14 @@ private HttpResponse decrypt(ExchangeRequest req, byte[] key) throws Exception {
return createUser(username, ltpk, proof);
}

private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}

private HttpResponse createUser(byte[] username, byte[] ltpk, byte[] proof) throws Exception {
HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest());
hkdf.init(
Expand Down
34 changes: 31 additions & 3 deletions src/main/java/io/github/hapjava/server/impl/pairing/PairSetupRequest.java
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,38 @@ static class ExchangeRequest extends PairSetupRequest {
private final byte[] authTagData;

public ExchangeRequest(DecodeResult d) {
messageData = new byte[d.getLength(MessageType.ENCRYPTED_DATA) - 16];
// Get the complete encrypted data field
byte[] encryptedData = d.getBytes(MessageType.ENCRYPTED_DATA);
logger.debug("ExchangeRequest: Total encrypted data length: {}", encryptedData.length);
logger.debug("ExchangeRequest: Raw encrypted data: {}", bytesToHex(encryptedData));

// For HomeKit M5, the encrypted data contains ciphertext + 16-byte auth tag
// The auth tag is the LAST 16 bytes
if (encryptedData.length < 16) {
throw new RuntimeException(
"Encrypted data too short, expected at least 16 bytes for auth tag");
}

int ciphertextLength = encryptedData.length - 16;
messageData = new byte[ciphertextLength];
authTagData = new byte[16];
d.getBytes(MessageType.ENCRYPTED_DATA, messageData, 0);
d.getBytes(MessageType.ENCRYPTED_DATA, authTagData, messageData.length);

// Copy ciphertext (everything except last 16 bytes)
System.arraycopy(encryptedData, 0, messageData, 0, ciphertextLength);
// Copy auth tag (last 16 bytes)
System.arraycopy(encryptedData, ciphertextLength, authTagData, 0, 16);

logger.debug("ExchangeRequest: Parsed ciphertext length: {}", messageData.length);
logger.debug("ExchangeRequest: Parsed ciphertext: {}", bytesToHex(messageData));
logger.debug("ExchangeRequest: Parsed auth tag: {}", bytesToHex(authTagData));
}

private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}

public byte[] getMessageData() {
Expand Down