[icloud] Implement SRP authentication algorithm (#19832)

Signed-off-by: Simon Spielmann <simon.spielmann@gmx.de>
This commit is contained in:
Simon Spielmann
2025-12-22 15:12:05 +01:00
committed by GitHub
parent 2fe85414b7
commit a727e121e2
9 changed files with 549 additions and 35 deletions
@@ -15,4 +15,21 @@
<description>The Apple iCloud is used to retrieve data such as the battery level or current location of one or
multiple Apple devices connected to an iCloud account.</description>
<properties>
<bouncycastle.version>1.81</bouncycastle.version>
</properties>
<dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcutil-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
*
* Exception for errors during calls of the iCloud API.
*
* @author Simon Spielmann - Initial contribution
*/
@NonNullByDefault
public class ICloudApiAuthenticationException extends ICloudApiResponseException {
private static final long serialVersionUID = 1L;
/**
* The constructor.
*
* @param url URL for which the exception occurred
* @param statusCode HTTP status code which was reported
*/
public ICloudApiAuthenticationException(String url, int statusCode, String body) {
super(url, statusCode, body);
}
@Override
public String toString() {
return "ICloudApiAuthenticationException [statusCode=" + statusCode + ", body=" + body + "]";
}
/**
* Returns the reason for the authentication error based on the status code.
*
* @return statusCode HTTP status code of failed request.
*/
public String getReason() {
switch (this.statusCode) {
case 421:
return "LOGIN_TOKEN_EXPIRED";
case 409:
return "2FA_REQUIRED";
case 450:
return "FIND_MY_AUTH_REQUIRED";
case 500:
return "GENERAL_AUTH_ERROR";
default:
return "UNKNOWN_ERROR";
}
}
/**
* Checks if the given status code is related to an authentication error.
*
* @param statusCode HTTP status code to check.
* @return true if the status code indicates an authentication error, false otherwise.
*/
public static boolean isAuthError(int statusCode) {
return statusCode == 409 || statusCode == 421 || statusCode == 450 || statusCode == 500;
}
}
@@ -24,7 +24,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
public class ICloudApiResponseException extends Exception {
private static final long serialVersionUID = 1L;
private int statusCode;
protected int statusCode;
protected String body;
/**
* The constructor.
@@ -32,9 +33,15 @@ public class ICloudApiResponseException extends Exception {
* @param url URL for which the exception occurred
* @param statusCode HTTP status code which was reported
*/
public ICloudApiResponseException(String url, int statusCode) {
public ICloudApiResponseException(String url, int statusCode, String body) {
super(String.format("Request %s failed with %s.", url, statusCode));
this.statusCode = statusCode;
this.body = body;
}
@Override
public String toString() {
return "ICloudApiResponseException [statusCode=" + statusCode + ", body=" + body + "]";
}
/**
@@ -13,12 +13,14 @@
package org.openhab.binding.icloud.internal;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bouncycastle.crypto.CryptoException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.icloud.internal.utilities.JsonUtils;
@@ -28,6 +30,8 @@ import org.openhab.core.storage.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonSyntaxException;
/**
*
* Class to access Apple iCloud API.
@@ -39,9 +43,6 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault
public class ICloudService {
/**
*
*/
private static final String ICLOUD_CLIENT_ID = "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d";
private final Logger logger = LoggerFactory.getLogger(ICloudService.class);
@@ -84,10 +85,15 @@ public class ICloudService {
*
* @param forceRefresh Force a new authentication
* @return {@code true} if authentication was successful
*
* @throws IOException if I/O error occurred
* @throws InterruptedException if request was interrupted
* @throws CryptoException if a cryptographic error occurred
* @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
* @throws NoSuchAlgorithmException if the requested cryptographic algorithm is not available
*/
public boolean authenticate(boolean forceRefresh) throws IOException, InterruptedException {
public boolean authenticate(boolean forceRefresh) throws IOException, InterruptedException,
ICloudApiResponseException, CryptoException, NoSuchAlgorithmException {
boolean loginSuccessful = false;
if (this.session.getSessionToken() != null && !forceRefresh) {
try {
@@ -101,29 +107,33 @@ public class ICloudService {
if (!loginSuccessful) {
logger.debug("Authenticating as {}...", this.appleId);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("accountName", this.appleId);
requestBody.put("password", this.password);
requestBody.put("rememberMe", true);
if (session.hasToken()) {
requestBody.put("trustTokens", new String[] { this.session.getTrustToken() });
} else {
requestBody.put("trustTokens", new String[0]);
}
List<Pair<String, String>> headers = getAuthHeaders();
try {
this.session.post(AUTH_ENDPOINT + "/signin?isRememberMeEnabled=true", JsonUtils.toJson(requestBody),
headers);
} catch (ICloudApiResponseException ex) {
return false;
SrpAuthentication auth = new SrpAuthentication(appleId, password, getAuthHeaders());
auth.auth(AUTH_ENDPOINT, session);
} catch (ICloudApiAuthenticationException ex) {
if (ex.getStatusCode() == 500) {
logger.debug("Authentication failed.", ex);
return false;
} else {
getMfaAuthOptions();
}
}
}
return authenticateWithToken();
}
private void getMfaAuthOptions()
throws JsonSyntaxException, IOException, InterruptedException, ICloudApiResponseException {
List<Pair<String, String>> headers = ListUtil.replaceEntries(getAuthHeaders(),
List.of(Pair.of("Accept", "application/json")));
addSessionHeaders(headers);
@Nullable
Map<String, Object> localSessionData = JsonUtils.toMap(session.get(AUTH_ENDPOINT, headers));
if (localSessionData != null) {
data = localSessionData;
}
}
/**
* Try authentication with stored session token. Returns {@code true} if authentication was successful.
*
@@ -172,12 +182,12 @@ public class ICloudService {
}
/**
* @param pair
* @return
*
* @return List of headers required for authentication requests.
*/
private List<Pair<String, String>> getAuthHeaders() {
return new ArrayList<>(List.of(Pair.of("Accept", "*/*"), Pair.of("Content-Type", "application/json"),
Pair.of("X-Apple-OAuth-Client-Id", ICLOUD_CLIENT_ID),
return new ArrayList<>(List.of(Pair.of("Accept", "application/json, text/javascript"),
Pair.of("Content-Type", "application/json"), Pair.of("X-Apple-OAuth-Client-Id", ICLOUD_CLIENT_ID),
Pair.of("X-Apple-OAuth-Client-Type", "firstPartyAuth"),
Pair.of("X-Apple-OAuth-Redirect-URI", HOME_ENDPOINT),
Pair.of("X-Apple-OAuth-Require-Grant-Code", "true"),
@@ -144,10 +144,6 @@ public class ICloudSession {
logger.trace("Result {} {}\nHeaders -----\n{}\nBody -----\n{}\n------\n", url, response.statusCode(),
response.headers(), responseBodyAsString);
if (response.statusCode() >= 300) {
throw new ICloudApiResponseException(url, response.statusCode());
}
// Store headers to reuse authentication
this.data.accountCountry = response.headers().firstValue("X-Apple-ID-Account-Country")
.orElse(getAccountCountry());
@@ -158,6 +154,12 @@ public class ICloudSession {
this.stateStorage.put(SESSION_DATA_KEY, JsonUtils.toJson(this.data));
if (ICloudApiAuthenticationException.isAuthError(response.statusCode())) {
throw new ICloudApiAuthenticationException(url, response.statusCode(), responseBodyAsString);
} else if (response.statusCode() >= 300) {
throw new ICloudApiResponseException(url, response.statusCode(), responseBodyAsString);
}
return responseBodyAsString;
}
@@ -0,0 +1,300 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.bouncycastle.crypto.CryptoException;
import org.bouncycastle.crypto.Digest;
import org.bouncycastle.crypto.agreement.srp.SRP6Util;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.util.encoders.Base64;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.icloud.internal.utilities.JsonUtils;
import org.openhab.binding.icloud.internal.utilities.Pair;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
/**
*
* SrpAuthentication implements the SRP authentication for iCloud.
*
* @author Simon Spielmann - Initial contribution
*/
@NonNullByDefault
public class SrpAuthentication {
private final String password;
private final List<Pair<String, String>> sessionHeaders;
// N and g values from RFC 5054 - 2048 bit group
private static final BigInteger N = new BigInteger(
"2176617445861743577319100889180275378190766837425553851114464322"
+ "4689886235383840957210909013086056401571399717235807266581649606"
+ "4721484102914133641521973644771808873956554837381150726774022351"
+ "0176252190156982074029314952962041933326626207347105454836873603"
+ "9519702486226506248861060256971802984953561121442680157668000761"
+ "4299882224570904138739739701719270939921147517651680636147611196"
+ "1547623342209644278311797123637164733387141433589577347466730896"
+ "7050807005509320424799678417036867928316761272274230314067548291"
+ "1335824795830614395775593471019617714061736843785227034834953370"
+ "37655006751328447510550299250924469288819");
private final static BigInteger g = BigInteger.valueOf(2L);
// Username
private String I;
private static MessageDigest md;
static {
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new ExceptionInInitializerError(e);
}
}
/**
* Implements SRP authentication according to Apple's specifications.
*
* @param accountName the account name (username)
* @param password user password
* @param sessionHeaders list of session headers
*/
public SrpAuthentication(String accountName, String password, List<Pair<String, String>> sessionHeaders) {
this.I = accountName;
this.password = password;
this.sessionHeaders = sessionHeaders;
}
/**
* Convert BigInteger to byte array without leading zero byte
*
* @param data BigInteger to convert
* @return byte array representation of BigInteger without leading zero byte
*/
private static byte[] toByteArray(BigInteger data) {
byte[] signedBytes = data.toByteArray();
if (signedBytes[0] == 0x00) {
// Removing leading zero byte
byte[] unsignedBytes = new byte[signedBytes.length - 1];
System.arraycopy(signedBytes, 1, unsignedBytes, 0, unsignedBytes.length);
return unsignedBytes;
} else {
return signedBytes;
}
}
/**
* Perform SRP authentication
*
* @param authEndpoint the authentication endpoint URL
* @param httpClient the HTTP client session
*/
public void auth(String authEndpoint, ICloudSession httpClient) throws IOException, InterruptedException,
ICloudApiResponseException, CryptoException, NoSuchAlgorithmException {
SrpPassword srpPassword = new SrpPassword(password);
byte[] clientA = new byte[256];
new SecureRandom().nextBytes(clientA);
BigInteger a = new BigInteger(1, clientA);
BigInteger A = g.modPow(a, N);
// Prepare initial authentication request
Map<String, Object> initData = Map.of("a", b64Encode(A), "accountName", I, "protocols",
new String[] { "s2k", "s2k_fo" });
String initResponse = httpClient.post(authEndpoint + "/signin/init", JsonUtils.toJson(initData),
sessionHeaders);
// Parse response
JsonObject initBody = parseJson(initResponse);
if (initBody == null) {
throw new ICloudApiResponseException("Failed to parse SRP init response", 520, "");
}
BigInteger B = new BigInteger(1, b64Decode(initBody.get("b").getAsString()));
byte[] s = b64Decode(initBody.get("salt").getAsString());
String c = initBody.get("c").getAsString();
int iterations = initBody.get("iteration").getAsInt();
int keyLength = 32;
srpPassword.setEncryptInfo(s, iterations, keyLength);
// SRP-6a safety check
if (B.mod(N).equals(BigInteger.ZERO)) {
throw new CryptoException("Invalid server public value B");
}
// Calculate S
Digest digest = new SHA256Digest();
BigInteger x = SRP6Util.calculateX(digest, N, s, "".getBytes(StandardCharsets.UTF_8), srpPassword.encode());
BigInteger u = SRP6Util.calculateU(digest, N, A, B);
BigInteger k = SRP6Util.calculateK(digest, N, g);
BigInteger v = g.modPow(x, N);
BigInteger S = B.subtract(k.multiply(v)).modPow(a.add(u.multiply(x)), N);
byte[] K = sha256(toUnsigned(S, 256));
// Compute client proof M1 = H(H(N) xor H(g) || H(I) || s || A || B || K)
byte[] HN = sha256(toUnsigned(N, 256));
byte[] Hg = sha256(toUnsigned(g, 256));
byte[] Hxor = xor(HN, Hg);
byte[] HI = sha256(I.getBytes(StandardCharsets.UTF_8));
byte[] M1 = sha256(concat(Hxor, HI, s, toUnsigned(A, 256), toUnsigned(B, 256), K));
// Compute expected server proof M2 = H(A || M1 || K)
byte[] M2 = sha256(concat(toUnsigned(A, 256), M1, K));
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("accountName", I);
requestBody.put("c", c);
requestBody.put("m1", b64Encode(M1));
requestBody.put("m2", b64Encode(M2));
requestBody.put("rememberMe", true);
requestBody.put("trustTokens", new String[] { httpClient.getTrustToken() });
httpClient.post(authEndpoint + "/signin/complete?isRememberMeEnabled=true", JsonUtils.toJson(requestBody),
sessionHeaders);
}
/**
* Parse JSON response using Gson
*
* @param jsonString the JSON response string
* @return the parsed JsonObject
*/
private @Nullable JsonObject parseJson(String jsonString) {
Gson gson = new Gson();
return gson.fromJson(jsonString, JsonObject.class);
}
/**
* Computes the SHA-256 hash of the given data.
*
* @param data the input data.
* @return the SHA-256 hash as a byte array.
* @throws NoSuchAlgorithmException if SHA-256 algorithm is not available.
*/
private static byte[] sha256(byte[] data) {
md.reset();
return md.digest(data);
}
/**
* Converts a BigInteger to an unsigned byte array of the specified length.
* If the byte array representation of the BigInteger is shorter than the specified length,
* it is left-padded with zeros. If it is longer, an exception is thrown.
*
* @param bigInteger the BigInteger to convert.
* @param length the desired length of the resulting byte array.
* @return a byte array of the given length representing the unsigned BigInteger.
*/
private static byte[] toUnsigned(BigInteger bigInteger, int length) {
byte[] raw = bigInteger.toByteArray();
if (raw.length == length && raw[0] != 0) {
return raw;
}
byte[] unsigned;
if (raw[0] == 0) {
// strip leading sign byte
unsigned = new byte[raw.length - 1];
System.arraycopy(raw, 1, unsigned, 0, unsigned.length);
} else {
unsigned = raw;
}
if (unsigned.length == length) {
return unsigned;
}
// pad to fixed length
byte[] padded = new byte[length];
System.arraycopy(unsigned, 0, padded, length - unsigned.length, unsigned.length);
return padded;
}
/**
* Concatenates multiple byte arrays into a single byte array.
*
* @param parts the byte arrays to concatenate
* @return the concatenated byte array
*/
private static byte[] concat(byte[]... parts) {
int total = Arrays.stream(parts).mapToInt(p -> p.length).sum();
byte[] out = new byte[total];
int pos = 0;
for (byte[] p : parts) {
System.arraycopy(p, 0, out, pos, p.length);
pos += p.length;
}
return out;
}
/**
* XORs two byte arrays of the same length.
*
* @param a first byte array
* @param b second byte array
* @return the result of XORing the two byte arrays
*/
private static byte[] xor(byte[] a, byte[] b) {
assert (a.length == b.length);
byte[] result = new byte[a.length];
for (int i = 0; i < a.length; i++) {
result[i] = (byte) (a[i] ^ b[i]);
}
return result;
}
/**
* Base64 encode
*
* @param data BigInteger to encode
*/
private static String b64Encode(BigInteger data) {
return b64Encode(toByteArray(data));
}
/**
* Base64 encode
*
* @param data byte array to encode
*/
private static String b64Encode(byte[] data) {
return Base64.toBase64String(data);
}
/**
* Base64 decode
*
* @param data string to decode
*/
private static byte[] b64Decode(String data) {
return Base64.decode(data);
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator;
import org.bouncycastle.crypto.params.KeyParameter;
/**
* SrpPassword represents a password for SRP authentication.
*
* @author Simon Spielmann - Initial contribution
*/
public class SrpPassword {
private final byte[] passwordHash;
private byte[] salt;
private Integer iterations;
private Integer keyLength;
/**
* Constructor for SrpPassword.
*
* @param password the password as a String
*/
public SrpPassword(String password) {
this.passwordHash = sha256(password);
}
/**
* Calculates the SHA-256 hash of the input string.
*
* @param input the input string
* @return the SHA-256 hash as a byte array
*/
private byte[] sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return digest.digest(input.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not available", e);
}
}
/**
* Sets the encryption information.
*
* @param salt The salt
* @param iterations Number of iterations
* @param keyLength Key length
*/
public void setEncryptInfo(byte[] salt, int iterations, int keyLength) {
this.salt = salt;
this.iterations = iterations;
this.keyLength = keyLength;
}
/**
* Encodes the password using PBKDF2 with the provided encryption information.
*
* @return The encoded password as a byte array
*/
public byte[] encode() {
if (salt == null || iterations == null || keyLength == null) {
throw new IllegalStateException("Encrypt info not set");
}
try {
PKCS5S2ParametersGenerator gen = new PKCS5S2ParametersGenerator(new SHA256Digest());
gen.init(passwordHash, salt, iterations);
KeyParameter key = (KeyParameter) gen.generateDerivedParameters(keyLength * 8);
return key.getKey();
} catch (Exception e) {
throw new RuntimeException("Error during PBKDF2 encoding", e);
}
}
}
@@ -45,8 +45,6 @@ import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonSyntaxException;
/**
* Retrieves the data for a given account from iCloud and passes the information to
* {@link org.openhab.binding.icloud.internal.discovery.ICloudDeviceDiscovery} and to the {@link ICloudDeviceHandler}s.
@@ -404,7 +402,7 @@ public class ICloudAccountBridgeHandler extends BaseBridgeHandler {
"Status = " + statusCode + ", Response = " + json);
}
logger.debug("iCloud bridge data refresh complete.");
} catch (NumberFormatException | JsonSyntaxException e) {
} catch (RuntimeException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"iCloud response invalid: " + e.getMessage());
}
@@ -18,11 +18,13 @@ import static org.openhab.binding.icloud.internal.ICloudBindingConstants.THING_T
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import org.bouncycastle.crypto.CryptoException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.BeforeEach;
@@ -31,6 +33,7 @@ import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.openhab.binding.icloud.internal.ICloudApiResponseException;
import org.openhab.binding.icloud.internal.ICloudBindingConstants;
import org.openhab.binding.icloud.internal.ICloudService;
import org.openhab.binding.icloud.internal.SrpPassword;
import org.openhab.binding.icloud.internal.handler.ICloudDeviceHandler;
import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudAccountDataResponse;
import org.openhab.binding.icloud.internal.utilities.JsonUtils;
@@ -87,9 +90,25 @@ public class TestICloud {
iCloudTestPassword = sysPropPW != null ? sysPropPW : "notset";
}
@Test
public void testSrpPassword() {
String password = "testpassword";
byte[] salt = new byte[] { 1, 2, 3, 4 };
int iterations = 20622;
int keyLength = 32;
SrpPassword srpPassword = new SrpPassword(password);
srpPassword.setEncryptInfo(salt, iterations, keyLength);
byte[] expected = new byte[] { 66, -77, 114, 66, -54, -84, 100, 100, 77, 71, -77, 83, -6, -42, 88, 43, -78, 95,
35, 45, -105, 111, -9, 106, 12, -89, -111, 63, -36, -34, -101, -104 };
assertArrayEquals(expected, srpPassword.encode());
}
@Test
@EnabledIfSystemProperty(named = "icloud.test.email", matches = ".*", disabledReason = "Only for manual execution.")
public void testAuth() throws IOException, InterruptedException, ICloudApiResponseException, JsonSyntaxException {
public void testAuth() throws IOException, InterruptedException, ICloudApiResponseException, JsonSyntaxException,
CryptoException, NoSuchAlgorithmException {
File jsonStorageFile = new File(System.getProperty("user.home"), "openhab.json");
logger.info(jsonStorageFile.toString());