Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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