DIY Airtags
Using Macless Haystack, anyone can use the Apple Findmy network with a BLE beacon and Linux server. No Apple device needed! Lets get started. I developed this program for the Nicenano v1(NRF52840) using the Zephyr 2.5.0 SDK:
main.cpp:
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gap.h>
#include <zephyr/bluetooth/controller.h>
static uint8_t pubKey[29] = {
<SET THIS TO THE HEXDUMP OF THE RAW PUBLIC KEY, NOT BASE64 ENCODED!!!>
};
static uint8_t adv_data[29] = {
0x4c, 0x00, /* Company ID (Apple) */
0x12, 0x19, /* Offline Finding type and length */
0x00, /* State */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, /* First two bits */
0x00, /* Hint (0x00) */
};
void set_payload_from_key(uint8_t *payload, uint8_t *public_key) {
/* copy last 22 bytes */
memcpy(&payload[5], &public_key[7], 22);
/* append two bits of public key */
payload[27] = public_key[1] >> 6;
}
static const struct bt_data ad[] = {
/* STEP 4.1.2 - Set the advertising flags */
//BT_DATA_BYTES(BT_DATA_FLAGS, BT_LE_AD_NO_BREDR),
/* STEP 4.1.3 - Set the advertising packet data */
//BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME, sizeof(CONFIG_BT_DEVICE_NAME)),
};
static const struct bt_data sd[] = {
BT_DATA(BT_DATA_MANUFACTURER_DATA, adv_data,sizeof(adv_data)),
};
void set_addr_from_key(bt_addr_t *addr, uint8_t *public_key) {
addr->val[0] = public_key[6];
addr->val[1] = public_key[5];
addr->val[2] = public_key[4];
addr->val[3] = public_key[3];
addr->val[4] = public_key[2];
addr->val[5] = public_key[1] | 0b11000000;
}
static bt_addr_le_t addr;
static bt_addr_t bt_addr;
int main(void) {
set_addr_from_key(&bt_addr,pubKey);
addr.a=bt_addr;
addr.type=BT_ADDR_LE_RANDOM;
//bt_addr_le_from_str("FF:AA:AA:AA:AA:FF", "random", &addr);
// bt_addr_le_from_str("FF:AA:AA:AA:AA:FF", "random", &addr2);
bt_id_create(&addr, NULL);
// bt_id_create(&addr2, NULL);
struct bt_le_adv_param adv_param = {
.id = 0,
.sid = 0,
.secondary_max_skip = 0,
.options = BT_LE_ADV_OPT_USE_IDENTITY,
.interval_min = 3200,
.interval_max = 3300,
.peer = NULL,
};
bt_enable(NULL);
set_payload_from_key(adv_data,pubKey);
bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
while(1) {
k_sleep(K_FOREVER);
}
}
prj.conf:
CONFIG_BT=y
CONFIG_BT_ID_MAX=2
CONFIG_BT_CTLR_TX_PWR_PLUS_4=y
CONFIG_LOG=n
CONFIG_GPIO=n
CONFIG_SERIAL=n
CONFIG_CONSOLE=n
CONFIG_PINCTRL=n
CONFIG_SPI=n
CONFIG_FLASH=n
CONFIG_USB_CDC_ACM_LOG_LEVEL_OFF=n
CONFIG_USB_DEVICE_STACK=n
CONFIG_MPU_ALLOW_FLASH_WRITE=n
CONFIG_NVS=n
CONFIG_SETTINGS_NVS=n
CONFIG_FLASH_PAGE_LAYOUT=n
CONFIG_FLASH_MAP=n
CONFIG_PM_DEVICE=y
The prj.conf file is very important!! It disabled all unessecary SPI, external SPI flash, GPIO, etc to minimize power consumption. I measureed with my multimeter and its roughtly 30micro A!!! Very good. A single CR2032 will last around 250 days. Lets try with a Xiao BLE to see if I can get even better low power usage. First I need to unbrick it because I accidently flashed wrong firmware onto it. DFU does not work at all, so I need to flash the original bootloader back.
2025-11-29
Looks like Apple changed their 2FA SMS endpoint again, so you cannot receive SMS anymore. A couple months earlier, this worked without any problem. Here is the GitHub issue. Lets contribute and fix this problem. You get HTTP 405 on the gsa.apple.com/auth/verify/phone/ endpoint when you press enter. Lets change to https://idmsa.apple.com/appleauth/auth/verify/phone/securitycode, which is the new endpoint. I also confirmed by logging into https://www.icloud.com and logging all network traffic via developer tools.
In the file endpoint/register/pypush_gsa_icloud.py on line 286 change to:
if code == "":
code = request_code(headers,sms_id)
else:
code = request_code(headers,sms_id)
This part is responsible for sending the SMS if the intial sending fails. It used to work a couple months ago. After you logged in via the gsa.apple.com endpoint, an SMS would be sent, but it seems Apple disabled it as I cannot receive anything. I also changed to pass the sms_id into the function. The original code hardcodes to index 1, but in my case its actually index 2. Making this variable dynamic is also a good practice. Maybe in the future we can add selection via index if the user has multiple phone numbers associated with their Apple account.
At line 317, modify the function:
def request_code(headers,sms_id):
# This will send the 2FA code to the user's phone over SMS
# We don't care about the response, it's just some HTML with a form for entering the code
# Easier to just use a text prompt
logger.debug(headers)
body = {"phoneNumber": {"id": sms_id}, "mode": "sms"}
logger.info(f"Sending SMS via id {sms_id}")
with requests.put(
"https://idmsa.apple.com/appleauth/auth/verify/phone",
I added some logger for debugging purposes. Change the body to take in our sms_id variable to construct the request body, then send the PUT request via the new updated endpoint. We can test by running the code with python -u endpoint/mh_endpoint.py. Make sure to also setup your pyenv and use pip -r requirements.txt in the endpoiint directory to install the dependencies. Confirming that this works, we also need to modify the Dockerfile, so we compile our modified src code. Right now, it actually fetches from GitHub and ignores local changes:
FROM python:slim
ENV TERM xterm
WORKDIR /app
#RUN apt-get update && apt-get install -y curl nano iproute2 git cron
RUN apt-get update && apt-get install -y curl
# Clone endpoint-folder
#RUN git init
#RUN git remote add origin https://github.com/dchristl/macless-haystack.git
#RUN git config core.sparseCheckout true
#RUN echo "endpoint" >> .git/info/sparse-checkout
#RUN git pull origin main
#RUN git checkout main
COPY ./mh_endpoint.py endpoint/mh_endpoint.py
COPY ./register endpoint/register
COPY ./requirements.txt endpoint/requirements.txt
COPY ./mh_config.py endpoint/mh_config.py
# Configure python
#RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r endpoint/requirements.txt
# Update server files on startup
#CMD ["sh", "-c", "./endpoint/updateRepo && python -u endpoint/mh_endpoint.py"]
CMD ["sh", "-c", "python -u endpoint/mh_endpoint.py"]
Above is what I changed the dockerfile to. Commented out the original code and put the modified one. The most important one is copying the src code from the local directory using:
COPY ./mh_endpoint.py endpoint/mh_endpoint.py
COPY ./register endpoint/register
COPY ./requirements.txt endpoint/requirements.txt
COPY ./mh_config.py endpoint/mh_config.py
Then at the end, modify the run CMD to CMD ["sh", "-c", "python -u endpoint/mh_endpoint.py"]. So it doesn’t call the updateRepo script, which we don’t want. Run docker build -f Dockerfile . in the endpoint directory to build the custom image. Then deploy using Docker compose like usual.
Another bug I noticed is the HTTP server implementation is not multi threaded, so sometimes the server locks up and its really annoying. Lets fix this as well. In the mh_endpoint.py file, first import threading librariesfrom socketserver import ThreadingMixIn. Then define a ThreadedHTTPServer class:
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
In the main fn, change HTTPServer to your new class ThreadedHTTPServer. Its easy as that to enable multi threading.
#httpd = HTTPServer((mh_config.getBindingAddress(), mh_config.getPort()), Handler)
httpd = ThreadedHTTPServer((mh_config.getBindingAddress(), mh_config.getPort()), Handler)
httpd.timeout = 30
We also set a timeout on the POST request to Apple when retrieving location reports. Due to this being single threaded as well, it sometimes times out, which can lock the server for up to 60 seconds. I add a 5 second timeout, so the server doesn’t get locked up. A better fix is to implement async server:
try:
with requests.post("https://gateway.icloud.com/acsnservice/fetch", timeout=5, auth=getAuth(regenerate=False, second_factor='sms'),
Thats it, so far the server is still running and hasn’t locked up. I also added a healthcheck in my Docker compose file to monitor and get alerts if the server dies for any reason.