0x00 背景
因为公司使用刷脸打卡,这就导致有时候打了卡,却突然失忆,忘了自己有没有打卡;或者直接就忘了打卡,并且也忘了自己有没有打卡。这几种情况都实实在在发生过的,上个班还要时时刻刻去回忆有没有打卡实在是非常痛苦的一件事。所以一直就想做一个提醒打卡的小程序。
0x01 设想的几种方案
第一种方案,是基于IOS快捷指令的自动化功能,设置到达或者离开目的地的提醒,但是这个自动化的方式非常鸡肋,一个是触发之后,还要手动点运行;二是到达或离开目的地根本就不提醒,反而是每次走到定位地址外的一个准确位置,反而又会准确提醒,不知道是不是地图位置有偏移还是咋滴。
第二种方案是想起之前阿里公众号中的一篇文章,就讲到在外卖取餐的场景中,如何准确识别外卖小哥到店取餐的解决方案,这给了我很大的启发,该场景跟我上下班的场景很是契合,只要在到店(到公司上班)识别的基础上,加上离店(下班回家)识别的功能即可。开干!
0x02 资料收集
遵循不重复造轮子原则,我首先在网上搜集了一下相关的信息,看看有没有现成的方案。
首先在知乎找到了《总是忘打卡?用iBeacon提醒你!》这篇文章,作者在万能淘宝上购买的iBeacon信标,使用Python搭配pybluez库探测iBeacon信号,根据iBeacon信号的消失或者出现来判断作者上班与下班的状态。
但是据了解,iPhone是会持续向外发射BLE信号的,既然有现成的信号源,就不用买万能淘宝的iBeacon信标了。
因为iPhone的BLE广播的是随机地址,那么要如何唯一确定该随机地址是我的设备呢? 首先我们要知道随机地址是什么意思,随机地址是蓝牙核心规范中的一个词条,那么在蓝牙核心规范中必然会有”随机地址”的定义和描述,附上蓝牙核心规范5.3下载链接。
0x03 实现方案
根据蓝牙核心规范5.3中[Vol 6] Part B, Section 1.3.2.3
的描述,可解析随机地址(iPhone BLE使用的正是可解析随机地址)可以使用设备配对过程中产生的IRK进行解析
- 将IRK以及随机地址的前24位prand作为输入,然后prand需要补齐128位变为r’
- 使用
[Vol 3] Part H, Section 2.2.2
的ah哈希函数,进行ah(IRK,r’),ah函数实际上就是NIST Publication FIPS-197中描述的AES-128加密,IRK作为key,r’作为plaintext - 取ah哈希运算结果的最后24位,跟随机地址的后24位进行比较,若一样则可判断为同一设备。
0x04 运行环境的选择
我脑海中最先想到的是树莓派,作为一个wifi 蓝牙 有线功能完备,软件支持完整,即插即用,并且技术成熟的ARM设备,确实是实现各种小功能的首选设备。
等等,为什么不在windows中实现呢?我的办公电脑又是24小时开机的,少维护一个设备多香啊。但是在实现的过程中发现pybluez的库在windows平台上并不支持BLE。。。但是万能的GayHub说:pybluez不行我这还有pysimpleble,尽管文档少,又不是不能用对吧。
0x05 获取IRK
既然随机地址是根据IRK来解析的,那首先就要获取到一个IRK。
首先,需要一个支持BLE的蓝牙适配器(我两台电脑蓝牙适配器可能因为不支持BLE配对,只能获取到Linkkey),以及Win10系统环境。
将电脑与手机进行配对,配对完成后,打开设备管理器,找到iPhone的蓝牙LE设备,打开属性窗口的事件选项卡,在信息栏记录蓝牙LE设备的设备号(如图5.1) 下载PsTools解压,使用管理员权限打开CMD,cd到解压好的PsTools根目录,执行命令
# 64位操作系统执行以下命令
PsExec64.exe -si regedit
# 32位操作系统执行以下命令
PsExec.exe -si regedit
执行命令后会打开注册表窗口,在左侧找到HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\services\BTHPORT\Parameters\Keys\
目录(如图 5.2)
第一层目录是本机蓝牙Mac地址,在第二层目录中找到设备管理器中记录下的设备号,点击该目录,记录右侧窗口的IRK秘钥串
PS:如果有 ESP32 开发板,可以烧录Decoding-Random-Bluetooth-Address项目的get_irk - esp32_irk.ino
,再使用手机跟ESP32配对,在串口监视器中即可看到打印出来的IRK。
另外,Decoding-Random-Bluetooth-Address这个项目中的nrf_ble_add文件夹中,有C语实现的程序,可以用来研究随机地址与irk匹配的具体算法,我仅参考了ah函数的具体实现过程,因为项目跑起来的发现随机地址和irk的匹配结果不对。
0x06 位置判断实现篇
## 随机地址校验器
class RandomAddressValidator(object):
def __init__(self):
self.irk = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
# 将irk的hex数值转换为字节数组
self.key = bytearray.fromhex(self.irk)
self.MODE = AES.MODE_ECB
# ah函数
def ah(self, k, _r):
aes = AES.new(k, self.MODE)
encrypted_text = aes.encrypt(_r)
return encrypted_text.hex()
def validate(self, random_address):
random_address = random_address.replace(':', '')
# padding用于填充prand位数
padding = '00000000000000000000000000'
# prand为随机地址的高24位
prand = random_address[:6]
# 用于最终校验的hash为随机地址的低24位
hash = random_address[-6:]
# 将填充到128位的prand转换为字节数组
_r = bytearray.fromhex(padding + prand)
# ah函数传入irk以及填充后的prand并返回运算结果
ah_res = self.ah(self.key, _r)
# 返回对比结果
return ah_res[-6:] == hash
校验器有了,接下来只需要循环扫描BLE信号,然后将扫描到的蓝牙地址进行一一校验,即可判断我的手机有没有在附近
pre_is_nearby = False
is_nearby = False
sendNotify_status = 'unknow'
while True:
adapters = simplepyble.Adapter.get_adapters()
if len(adapters) == 0:
print("No adapters found")
adapter = adapters[0]
print(f"Selected adapter: {adapter.identifier()} [{adapter.address()}]")
adapter.set_callback_on_scan_start(lambda: print("Scan started."))
adapter.set_callback_on_scan_stop(lambda: print("Scan complete."))
adapter.set_callback_on_scan_found(
lambda peripheral: print(f"Found {peripheral.identifier()} [{peripheral.address()}]"))
# Scan for 10 seconds
adapter.scan_for(10000)
peripherals = adapter.scan_get_results()
time.sleep(1)
pre_is_nearby = is_nearby
is_nearby = False
for i, peripheral in enumerate(peripherals):
validate_res = RandomAddressValidator().validate(peripheral.address())
print(f"[{peripheral.address()}] is_Matched:{validate_res}")
if validate_res:
is_nearby = True
break
now_localtime = time.strftime("%H:%M:%S", time.localtime())
# 上班时间
if "07:00:00" < now_localtime < "08:10:00":
if is_nearby and sendNotify_status != 'morning':
# 发送上班打卡提醒
sendNotify_status = 'morning'
# 下班时间
elif "16:59:00" < now_localtime < "20:00:00":
# 晚上会有暂时离开电脑,但并未下班的情况,需重复提醒
if pre_is_nearby != is_nearby:
sendNotify_status = 'unknow'
if not is_nearby and sendNotify_status != 'night':
# 发送下班打卡提醒
sendNotify_status = 'night'
# 每30秒扫描一次
time.sleep(30)
0x07 提醒方式实现篇
我实现的提醒方式主要有两种:邮件提醒 以及 公众号通知提醒。还有一种扩展的提醒方式,是基于IOS快捷指令的自动化功能,当收到邮件时,自动弹出快捷指令提醒,该方式可以将快捷指令设置为当用户手动运行快捷指令时,自动访问确认打卡的回调网址。
邮件提醒的实现非常简单,只需要在邮箱后台设置一下授权码,填入代码中即可:
# 这段代码是可以开箱即用的
def send_email(title, content):
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
# 设置smtp host
mail_host = 'smtp.163.com'
# 设置邮箱
mail_user = 'XXXXXXXXXX@163.com'
# 这个是授权码 不是密码 需要去163设置
mail_pass = 'XXXXXXXXXX'
sender = 'XXXXXXXXXX@163.com'
# 收件人邮箱
receivers = ['XXXXXXXXXX@qq.com']
# 构造message (邮件内容)
message = MIMEText(content, 'plain', 'utf-8')
message['Subject'] = title
message['From'] = sender
message['To'] = receivers[0]
# smtp = smtplib.SMTP(mail_host, 587)
try:
# smtp = smtplib.SMTP_SSL(mail_host, 465) # 启用SSL发信, 端口一般是465
smtp = smtplib.SMTP()
smtp.connect(mail_host)
smtp.set_debuglevel(1)
smtp.ehlo()
# smtp.starttls()
smtp.ehlo()
smtp.login(mail_user, mail_pass)
smtp.sendmail(sender, receivers, message.as_string())
smtp.quit()
print("mail has been send successfully.")
except smtplib.SMTPException as e:
print(e)
公众号通知提醒,需要开通一个测试号(可以实现模板消息(业务通知)发送的功能的最简单方式),可以打开这里注册,然后配置消息模板,模板内容中字段使用”{{字段名.DATA}}“的格式进行配置,配置好之后记录下模板id,在代码层面还要做以下工作:
# access_token的数据结构
access_token = {'token': None, 'expire_time': None}
# 检查access_token是否过期,过期则重新获取
def check_access_token(access_token_dict):
current_time = datetime.datetime.now()
if access_token_dict['token'] is None or current_time > access_token_dict['expire_time']:
token = get_access_token()
expire_time = current_time + datetime.timedelta(seconds=7200)
access_token_dict['token'] = token
access_token_dict['expire_time'] = expire_time
return access_token_dict
# 由于发送模板消息时需要携带access_token,token是有时间限制的,所以需要定期刷新token
def get_access_token():
# appid和secret可以在测试号后台的“测试号信息”中获取
url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=xxxxxxxxxx&secret=xxxxxxxxxx"
payload = {}
headers = {}
response = requests.request("GET", url, headers=headers, data=payload)
res = json.loads(response.text)
print(f"Get access_token successfully!\n[access_token]:{res['access_token']}")
return res['access_token']
# 发送模板消息的方法,
def send_template_msg(access_token, msg_content, msg_time):
url = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=" + access_token
payload = json.dumps({
# 接收者openid
"touser": "openid",
# 模板id
"template_id": "xxxxxxxxxx",
# 点击模板消息跳转的url
"url": "http://weixin.qq.com/download",
"topcolor": "#FF0000",
"data": {
"content": {
"value": msg_content,
"color": "#173177"
},
"time": {
"value": msg_time,
"color": "#173177"
}
}
})
headers = {
'Content-Type': 'application/json'
}
response = requests.request("POST", url, headers=headers, data=payload)
print(response.text)
return response.text
快捷指令提醒设置: 首先需要设置IOS的邮箱,我使用的是QQ邮箱,需要到QQ邮箱的设置中启用IMAP/SMTP服务,然后生成授权码。获取到授权码之后,到“邮件”中添加QQ邮箱账户,然后主机名填写imap.qq.com,用户名为QQ邮箱,密码为授权码,由于我没使用到SMTP服务,就没有进行配置。具体的配置过程可以参考QQ官方教程
快捷指令设置:找到快捷指令的自动化功能,具体操作很简单,就不再赘述了。
0x08 花絮
在Windows中使用Python实现受阻后,发现Windows SDK是提供BLE的完整支持的,于是又转过头准备在windows使用C#实现这个功能,微软还提供了若干例程,比如:BluetoothLE, BluetoothLEExplorer有C#开发经验的朋友可以优先考虑C#实现。因为C#我太久没接触了,要重新上手开发所花费的时间成本是巨大的,就还是将工作转到寻找Win10兼容的Python BLE库了。