Skip to content

Instantly share code, notes, and snippets.

@gretel
Last active March 7, 2026 23:51
Show Gist options
  • Select an option

  • Save gretel/cbea4e0f9c8593058049e114d1c462c0 to your computer and use it in GitHub Desktop.

Select an option

Save gretel/cbea4e0f9c8593058049e114d1c462c0 to your computer and use it in GitHub Desktop.
Extract AirTag private keys from jailbroken iPad (palera1n rootless, iOS 15.x). Gives JSON files to be used with https://github.com/malmeloo/hass-findmy. Runs from Mac over SSH.
#!/bin/bash
# airtag_ha.sh — Extract AirTag private keys from jailbroken iPad (palera1n rootless, iOS 15.x)
# Gives JSON files to be used with https://github.com/malmeloo/hass-findmy. Runs from Mac over SSH.
#
# Usage: ./airtag_ha.sh <IPAD_IP> [--port PORT] [--user USER]
# ./airtag_ha.sh --local <HEX_KEY>
set -euo pipefail
DATA="./data"; RECS="$DATA/records"; NAMES="$DATA/names"; DEC="$DATA/decoded"
VENV=".venv"; PY="$VENV/bin/python"; DECPY="$DATA/.decrypt.py"
IPAD_GETKEY="/var/jb/usr/bin/getkey"; IPAD_WORK="/var/tmp/airtag_extract"
SPD="/private/var/mobile/Library/com.apple.icloud.searchpartyd"
SSH_PORT=22; SSH_USER=root; IPAD_IP=""; HEX_KEY=""; LOCAL=false
ok() { printf " [ok] %s\n" "$*"; }
fail() { printf " [ERR] %s\n" "$*" >&2; exit 1; }
note() { printf " [!!] %s\n" "$*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--port) SSH_PORT="$2"; shift 2 ;;
--user) SSH_USER="$2"; shift 2 ;;
--local) LOCAL=true; shift ;;
--help|-h) echo "Usage: $0 <IPAD_IP> [--port PORT] [--user USER]"
echo " $0 --local <HEX_KEY>"; exit 0 ;;
*) if $LOCAL && [ -z "$HEX_KEY" ]; then HEX_KEY="$1"
elif [ -z "$IPAD_IP" ]; then IPAD_IP="$1"; fi; shift ;;
esac
done
! $LOCAL && [ -z "$IPAD_IP" ] && { echo "Usage: $0 <IPAD_IP>|--local <KEY>"; exit 1; }
# SSH multiplexing (password prompted once)
SSH_CTRL=$(mktemp -d)/ipad
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=5 -o ControlMaster=auto -o ControlPath=$SSH_CTRL -o ControlPersist=120 -p $SSH_PORT"
ssh_cmd() { ssh $SSH_OPTS "${SSH_USER}@${IPAD_IP}" "$@"; }
scp_to() { scp -o StrictHostKeyChecking=no -o ControlPath="$SSH_CTRL" -P "$SSH_PORT" "$1" "${SSH_USER}@${IPAD_IP}:$2"; }
trap 'ssh -o ControlPath="$SSH_CTRL" -O exit "${SSH_USER}@${IPAD_IP}" 2>/dev/null||true; rm -f "$SSH_CTRL"' EXIT
# -- Embedded sources ----------------------------------------------------------
read -r -d '' GETKEY_SRC << 'OBJ' || true
#import <Foundation/Foundation.h>
#import <Security/Security.h>
int main(){@autoreleasepool{
NSDictionary*q=@{(__bridge id)kSecClass:(__bridge id)kSecClassGenericPassword,
(__bridge id)kSecAttrService:@"BeaconStore",(__bridge id)kSecAttrAccount:@"BeaconStoreKey",
(__bridge id)kSecReturnData:@YES,(__bridge id)kSecReturnAttributes:@YES,
(__bridge id)kSecAttrAccessGroup:@"com.apple.icloud.searchpartyd"};
CFTypeRef r=NULL;OSStatus s=SecItemCopyMatching((__bridge CFDictionaryRef)q,&r);
if(s==0&&r){NSDictionary*d=(__bridge NSDictionary*)r;
NSData*g=d[(__bridge id)kSecAttrGeneric];NSData*v=d[(__bridge id)kSecValueData];
void(^pr)(NSString*,NSData*)=^(NSString*l,NSData*x){if(!x)return;
printf("%s:",[l UTF8String]);const unsigned char*b=[x bytes];
for(int i=0;i<[x length];i++)printf("%02x",b[i]);printf("\n");};
pr(@"GENERIC_HEX",g);pr(@"DATA_HEX",v);
}else printf("ERROR:%d\n",(int)s);}return 0;}
OBJ
ENT_PLIST='<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>keychain-access-groups</key><array>
<string>com.apple.icloud.searchpartyd</string>
<string>com.apple.icloud.searchpartyuseragent</string>
</array></dict></plist>'
# == Phase 1: Mac prerequisites ================================================
echo "--- Mac prerequisites"
command -v uv &>/dev/null || fail "uv not found (brew install uv)"
$LOCAL || xcrun -sdk iphoneos --show-sdk-path &>/dev/null || fail "Xcode iOS SDK not found"
[ -d "$VENV" ] || uv venv "$VENV" -q
uv pip install --python "$PY" -q pycryptodome
ok "uv + venv + pycryptodome"
# == Write Python decryptor ====================================================
mkdir -p "$RECS" "$NAMES" "$DEC/json"
cat > "$DECPY" << 'PYEOF'
# Decrypts searchpartyd .record files using BeaconStoreKey (AES-GCM).
# Uses plutil -extract per key to sidestep Apple's non-standard binary plist
# structures (list-as-dict-key) that crash plistlib and plutil -convert.
import sys, os, glob, plistlib, json, tempfile, subprocess
from Crypto.Cipher import AES
KEYS = ['privateKey','publicKey','sharedSecret','identifier','stableIdentifier',
'model','batteryLevel','isZeus','pairingDate','name','emoji',
'productId','vendorId','systemVersion','groupIdentifier']
def decrypt(fp, key):
with open(fp,'rb') as f: p = plistlib.loads(f.read())
n,t,c = bytes(p[0]),bytes(p[1]),bytes(p[2])
return AES.new(key, AES.MODE_GCM, nonce=n).decrypt_and_verify(c, t)
def extract(raw):
with tempfile.NamedTemporaryFile(suffix='.plist', delete=False) as f:
f.write(raw); tp = f.name
try:
d = {}
for k in KEYS:
r = subprocess.run(['plutil','-extract',k,'raw','-o','-',tp], capture_output=True)
if r.returncode: continue
v = r.stdout.strip()
if v == b'true': d[k] = True
elif v == b'false': d[k] = False
else:
s = v.decode('utf-8', errors='replace')
try: d[k] = float(s) if '.' in s else int(s)
except: d[k] = s
return d
finally: os.unlink(tp)
key = bytes.fromhex(sys.argv[1])
recs_dir, names_dir, dec_dir = sys.argv[2], sys.argv[3], sys.argv[4]
json_dir = os.path.join(dec_dir, "json"); os.makedirs(json_dir, exist_ok=True)
names = {}
for p in glob.iglob(os.path.join(names_dir, "**", "*.record"), recursive=True):
try:
d = extract(decrypt(p, key))
n = d.get('name','')
if n:
e = d.get('emoji','')
names[os.path.basename(os.path.dirname(p))] = f"{e} {n}".strip() if e else n
except: pass
recs = sorted(glob.glob(os.path.join(recs_dir, "*.record")))
if not recs: print("NO_RECORDS"); sys.exit(0)
for fp in recs:
uid = os.path.basename(fp).replace('.record','')
try:
raw = decrypt(fp, key)
with open(os.path.join(dec_dir, f"{uid}.plist"), 'wb') as f: f.write(raw)
d = extract(raw)
ident, stable = d.get('identifier', uid), d.get('stableIdentifier','')
model, batt = d.get('model','?'), d.get('batteryLevel', -1)
friendly = names.get(uid, names.get(ident, names.get(stable,'')))
display = friendly or ident
safe = "".join(c for c in display if c.isalnum() or c in ' ._-').strip() or uid
src = os.path.join(dec_dir, f"{uid}.plist")
dst = os.path.join(dec_dir, f"{safe}.plist")
if dst != src: os.replace(src, dst)
jp = os.path.join(json_dir, f"{safe}.json")
with open(jp,'w') as f: json.dump(d, f, indent=2)
is_tag = (model and 'TAG' in str(model).upper()) or d.get('isZeus')
bs = f"{int(batt*100)}%" if isinstance(batt,(int,float)) and 0<=batt<=1 else "?"
pk = "PK" if 'privateKey' in d else "no-pk"
print(f"{display}\t{uid}\t{'AirTag' if is_tag else 'Device'}\t{model}\t{bs}\t{pk}\t{jp}")
except Exception as e:
print(f"ERROR:{uid}\t{uid}\tERROR\t?\t?\tERROR\t{e}")
PYEOF
# == Phases 2-5: iPad ==========================================================
if ! $LOCAL; then
echo "--- iPad connection"
ssh_cmd "echo ok" &>/dev/null || fail "Cannot SSH to ${SSH_USER}@${IPAD_IP}:${SSH_PORT}"
ssh_cmd "test -d /var/jb" || fail "/var/jb not found -- palera1n rootless required"
ok "SSH to $IPAD_IP (rootless jailbreak)"
echo "--- getkey binary"
if ssh_cmd "test -x $IPAD_GETKEY" 2>/dev/null; then
ok "already installed"
else
TMPD=$(mktemp -d)
echo "$GETKEY_SRC" > "$TMPD/getkey.m"
xcrun -sdk iphoneos clang -arch arm64 -mios-version-min=15.0 \
-framework Foundation -framework Security -o "$TMPD/getkey" "$TMPD/getkey.m"
ssh_cmd "mkdir -p $IPAD_WORK"
echo "$ENT_PLIST" | ssh_cmd "cat > $IPAD_WORK/ent.xml"
scp_to "$TMPD/getkey" "$IPAD_WORK/getkey"
ssh_cmd "command -v ldid &>/dev/null || test -f /var/jb/usr/bin/ldid" 2>/dev/null \
|| ssh_cmd "apt install -y ldid" 2>/dev/null
ssh_cmd "chmod +x $IPAD_WORK/getkey && cp $IPAD_WORK/getkey $IPAD_GETKEY && ldid -S${IPAD_WORK}/ent.xml $IPAD_GETKEY"
rm -rf "$TMPD"
ok "compiled, uploaded, signed"
fi
echo "--- Extract BeaconStoreKey"
note "iPad must be UNLOCKED"
KEY_OUTPUT=$(ssh_cmd "$IPAD_GETKEY" 2>&1 || true)
[ -z "$KEY_OUTPUT" ] && fail "No output from getkey -- is iPad unlocked?"
echo "$KEY_OUTPUT" | grep -q "^ERROR:" 2>/dev/null && fail "$KEY_OUTPUT"
HEX_KEY=""
for pat in "^DATA_HEX:" "^GENERIC_HEX:" "KeychainData (hex):" "Generic (hex):"; do
HEX_KEY=$(echo "$KEY_OUTPUT" | grep "$pat" | head -1 | sed 's/.*://' | tr -d '[:space:]' || true)
[ -n "$HEX_KEY" ] && break
done
[ -z "$HEX_KEY" ] && { echo "$KEY_OUTPUT"; fail "Could not parse key"; }
ok "$(( ${#HEX_KEY} / 2 ))-byte key"
echo "--- Fetch records"
(ssh_cmd "cd $SPD/OwnedBeacons && tar cf - *.record 2>/dev/null" || true) | \
(tar xf - -C "$RECS" 2>/dev/null || true)
(ssh_cmd "cd $SPD && tar cf - BeaconNamingRecord 2>/dev/null" || true) | \
(tar xf - -C "$NAMES" --strip-components=1 2>/dev/null || true)
ok "$(find "$RECS" -name '*.record'|wc -l|tr -d ' ') beacon + $(find "$NAMES" -name '*.record' 2>/dev/null|wc -l|tr -d ' ') naming records"
fi
# == Phase 6: Decrypt ==========================================================
[ -z "$HEX_KEY" ] && fail "No key. Use: $0 --local <HEX_KEY>"
echo "--- Decrypt"
OUTPUT=$("$PY" "$DECPY" "$HEX_KEY" "$RECS" "$NAMES" "$DEC" 2>&1 || true)
echo "$OUTPUT" | grep -q "NO_RECORDS" 2>/dev/null && fail "No .record files in $RECS"
TAGS=0; DEVS=0; ERRS=0
while IFS=$'\t' read -r name uuid dtype model batt pk jp; do
[ -z "$name" ] && continue
if [[ "$name" == ERROR:* ]]; then
note "$uuid: $jp"; ((ERRS++)) || true; continue
fi
line="$name"
[ "$model" != "?" ] && line+=" ($model)"
[ "$batt" != "?" ] && line+=" [$batt]"
if [ "$pk" = "PK" ]; then
echo " [key] $line -> $jp"; ((TAGS++)) || true
else
echo " [---] $line"; ((DEVS++)) || true
fi
done <<< "$OUTPUT"
echo "---"
echo " $TAGS AirTag(s) with privateKey, $DEVS other device(s), $ERRS error(s)"
echo " Plists: $DEC/ JSON: $DEC/json/"
[ "$TAGS" -gt 0 ] && echo " Ready for hass-FindMy"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment