前言
总共花了4天休息时间做的小玩意,支持显示通用TOTP令牌(6位数字)和STEAM令牌(5位),同时也是个桌面时钟。
源码获取:GitHub
令牌原理
i> 简单的介绍一下两个令牌的原理,不感兴趣的可以参考下一节
原理
令牌类似于短信验证码
,是一种一次性密码OTP
,核心原理很简单,服务器和用户事先约定好一个Secret
,然后用户通过特殊的算法(一般用Secret
+ 时间戳
或者计数器
作为参数),得到一串字符,服务器也通过同样的算法得到一串字符,如果两者相同,那么就证明了我是我
。
常见的令牌大多是基于时间的令牌TOTP
,也就是用当前的时间戳作为输入,只要保证和服务端时间差不超过令牌有效期(常见的实现是30秒)就能确保通过验证。优点是令牌客户端可以不需要联网,缺点是可能因为时间差导致验证失败。
另一种实现是基于HMAC
的令牌HOTP
,这种令牌需要和服务器约定一个计数器,客户端刷新一次令牌,就告诉服务器,让两边的计数器同步,这样用户和服务器得到的结果才能一样,才能通过验证。缺点显而易见,就是令牌客户端需要联网。
STEAM的令牌系统类似于TOTP
,也是基于时间戳的,只是算法略有区别,最明显的区别是普通TOTP
计算得到的是6位数字,STEAM令牌是5位数字或字母。
x> Secret一定要保管好,千万不要向他人透露。
i> 完整代码位于utils/twofa.py
TOTP
详解
伪代码
#. 演示用的中间结果用【】 表示
0. 服务器和用户事先约定了一个密钥Secret,记为K【MNUHE6DXGEZDGNBVGY======】(Base32编码,也有的实现采用Base64编码)
1. 取当前时间戳/30,取整后记为C【53914423】
2. 使用C作为消息,K作为密钥,哈希方式一般为Sha-1,计算HMAC哈希值H【deb27e0ae7e70fc2d2527e6ed231967e26503f72】(16进制)
3. 取H最后一个字节的低4位,用作偏移,记为O【2】(即16进制C的最后一位)
4. 取H的第O到O+4字节【7e0ae7e7】,丢弃最高位,变为32位无符号整数,记为I【7e0ae7e7】(最高位本来就是0,所以不变)
5. 把I转成10进制数字【17602563747】,最低6位就是结果【643943】
Python实现
import hmac
import hashlib
from base64 import b32decode
from time import time
import struct
def get_totp_auth_code(secret: str, t: int = None):
if not t:
t = int(time() / 30)
key = b32decode(secret)
msg = struct.pack(">Q", t)
mac = hmac.new(key, msg, hashlib.sha1).digest()
offset = mac[-1] & 0x0f
binary = struct.unpack('>L', mac[offset:offset+4])[0] & 0x7fffffff
code = str(binary)[-6:].zfill(6)
return code
# 演示DEMO
print(get_totp_auth_code('MNUHE6DXGEZDGNBVGY======',53914423))
# 输出结果为 643943
Steam
令牌详解
伪代码
#. 演示用的中间结果用【】 表示
0. 服务器和用户事先约定了一个密钥Secret,记为K【Y2hyeHcxMjM0NTY=】(Base64编码)
1. 取当前时间戳/30,先取整【53914423】,然后转换成8字节,前方补0,记为C【000000000336ab37】(16进制)
2. 使用C作为消息,K作为密钥,哈希方式一般为Sha-1,计算HMAC哈希值H【deb27e0ae7e70fc2d2527e6ed231967e26503f72】(16进制)
3. 取H最后一个字节的低4位,用作偏移,记为O【2】(即16进制C的最后一位)
4. 取H的第O到O+4字节【7e0ae7e7】,丢弃最高位,变为32位无符号整数,记为I【7e0ae7e7】(最高位本来就是0,所以不变),为了方便计算转成10进制为【2114643943】
5. 创建一个字母表G【23456789BCDFGHJKMNPQRTVWXY】(去掉了一些容易混淆的字符)
6. 用I【2114643943】对26取余,记为M【9】,然后将I整除26,重复5次,得到5个M【9,13,7,12,25】(10进制)
7. 把5个M作为下标,从G中取出对应位置的字符,最后拼接起来就是结果【CH9GY】
i> 两个算法0~4步都是一模一样的,只是steam的算法多了一步处理过程
Python实现
import hmac
import hashlib
from base64 import b64decode
from time import time
import struct
def get_steam_auth_code(secret: str, t: int = None) -> str:
if not t:
t = int(time()/30)
msg = struct.pack(">Q", t)
key = b64decode(secret)
mac = hmac.new(key, msg, hashlib.sha1).digest()
offset = mac[-1] & 0x0f
binary = struct.unpack('>L', mac[offset:offset+4])[0] & 0x7fffffff
codestr = list('23456789BCDFGHJKMNPQRTVWXY')
chars = []
for _ in range(5):
chars.append(codestr[binary % 26])
binary //= 26
code = ''.join(chars)
return code
# 演示DEMO
print(get_steam_auth_code('Y2hyeHcxMjM0NTY=',53914423))
# 输出结果为 CH9GY
屏幕显示
i> 我使用的是微雪的2.7寸墨水屏,也可以使用别的尺寸的屏幕,自行修改图片生成函数进行适配即可。
买屏幕的时候直接买适配树莓派的就行了,基本上都提供Python的驱动,直接用就行,非常方便。
!> 常见的屏幕一般是SPI接口,树莓派默认是不开启SPI接口的,可以使用官方的工具rasbpi-config开启,或者手动编辑/boot/config.txt,在最后一行加一个dtparam=spi=on,重启后生效。
img.py
i> 如果对PIL绘图不是很熟悉可以看下一节。
'''
# @Author : Chr_
# @Date : 2021-03-30 17:04:24
# @LastEditors : Chr_
# @LastEditTime : 2021-04-03 00:26:15
# @Description : 图片生成器
'''
import time
from os import path
from typing import List, Tuple
from PIL import Image, ImageDraw, ImageFont
SCREEN_W = 264 # 屏幕显示宽度
SCREEN_H = 176 # 屏幕显示高度
CODE_W = 115 # 中间区域宽度
CODE_H = 27 # 中间区域一条令牌的高度
ICON_W = 20 # 左边图标区域宽度
ICON_H = 44 # 左边图标区域一个图标的高度(包含空白)
CLOCK_W = SCREEN_W-ICON_W - CODE_W
CLOCK_H = SCREEN_H
FONT_CODE = ImageFont.truetype(path.join('font', 'Courier Prime Code.ttf'), 25) # 令牌字体
FONT_TAG = ImageFont.truetype(path.join('font', 'CascadiaMono.ttf'), 12) # 令牌名称的字体
FONT_PAGE = ImageFont.truetype(
path.join('font', 'sarasa-fixed-sc-regular.ttf'), 15) # 页码字体
FONT_ICO = ImageFont.truetype(
path.join('font', 'sarasa-fixed-sc-regular.ttf'), 22) # 图标区域的字体
FONT_CLOCK = ImageFont.truetype(path.join('font', 'hartland.ttf'), 50) # 时钟字体
FONT_DATE = ImageFont.truetype(path.join('font', 'hartland.ttf'), 23) # 日期字体
FONT_TIPS = ImageFont.truetype(
path.join('font', 'sarasa-fixed-sc-regular.ttf'), 13) # 日期下方文字的字体
def generate_2fa_img(data: List[Tuple[str, str]], page: Tuple[int, int] = (1, 1), active: bool = True, tips: List[str] = None) -> Image:
def draw_icon() -> Image:
'''绘制图标'''
img = Image.new('L', (ICON_W, SCREEN_H), 0xff)
w, h = FONT_ICO.getsize('▲')
draw = ImageDraw.Draw(img)
ICO_TXT = ['▲', '▼', '●', '■'] if active else ['△', '▽', '○', '□']
for i in range(4):
x, y = (ICON_W-w)/2+1, i*44+(44-h)/2
draw.text((x, y), ICO_TXT[i], font=FONT_ICO, fill=0x00)
return img
def draw_auth() -> Image:
'''绘制令牌'''
def draw_auth_single(name: str, code: str) -> Image:
'''绘制单个令牌'''
# 计算尺寸
w1, h1 = FONT_CODE.getsize(code)
w2, h2 = FONT_TAG.getsize(name[:3])
W2, H2 = CODE_H, 18
W1, H1 = CODE_W-H2, CODE_H
# 画令牌
im_code = Image.new('L', (CODE_W, CODE_H), 0xff)
draw_code = ImageDraw.Draw(im_code)
draw_code.text(((W1-w1)/2, (H1-h1)/2),
code, font=FONT_CODE, fill=0x00)
# 画名称
im_name = Image.new('L', (W2, H2), 0xff)
draw_name = ImageDraw.Draw(im_name)
draw_name.text(((W2-w2)/2, (H2-h2)/2-2),
name[:3], font=FONT_TAG, fill=0x00)
draw_name.line(((0, 0), (W2, 0)), fill=0x00, width=1)
im_name = im_name.rotate(angle=90, expand=True)
im_code.paste(im_name, (W1, 0))
draw_code.rectangle((0, 0, CODE_W-1, CODE_H), outline=0)
return im_code
def draw_page_info() -> Image:
'''绘制页码'''
PAGE_H = SCREEN_H-6*CODE_H+2
im_page = Image.new('L', (CODE_W, PAGE_H), 0xff)
draw = ImageDraw.Draw(im_page)
draw.rectangle((0, 0, CODE_W-1, PAGE_H), outline=0)
curr, total = page
curr -= 1
if total < 1:
total = 1
if curr > total:
curr = 0
pages = ['-'] * total
if active:
pages[curr] = '≡'
else:
pages[curr] = '≡'
page_str = ' '.join(pages)
wp, hp = FONT_PAGE.getsize(page_str)
draw.text(((CODE_W-wp)/2, (PAGE_H-hp)/2-2),
page_str, font=FONT_PAGE, fill=0x00)
return im_page
img = Image.new('L', (CODE_W, SCREEN_H), 0xff)
y = -1
for name, code in data[:6]:
img_code = draw_auth_single(name, code)
img.paste(img_code, (0, y))
y += img_code.height
img_page = draw_page_info()
img.paste(img_page, (0, y))
return img
def draw_clock() -> Image:
'''绘制时钟'''
img = Image.new('L', (CLOCK_W, CLOCK_H), 0xff)
draw = ImageDraw.Draw(img)
time_str = time.strftime("%H:%M", time.localtime())
date_str = time.strftime("%Y-%m-%d", time.localtime())
# 画时钟
wc, hc = FONT_CLOCK.getsize(time_str)
wd, hd = FONT_DATE.getsize(date_str)
draw.text(((CLOCK_W-wc)/2+3, 10), time_str, font=FONT_CLOCK, fill=0x00)
draw.text(((CLOCK_W-wd)/2+3, 65), date_str, font=FONT_DATE, fill=0x00)
for i, tip in enumerate(tips, 0):
wt, ht = FONT_TIPS.getsize(tip)
draw.text(((CLOCK_W-wt)/2, 110+(ht+5)*i),
tip, font=FONT_TIPS, fill=0x00)
return img
if not active:
data = [('---', '-----')] * 6
if len(data) < 6:
data += [('---', '-----')] * 6
if not tips:
tips = ['By Chr_', 'Ver 0.01', 'chrxw.com']
full_img = Image.new('L', (SCREEN_W, SCREEN_H), 0xff)
img_auth = draw_auth()
full_img.paste(img_auth, (ICON_W, 0))
img_icon = draw_icon()
full_img.paste(img_icon, (0, 0))
img_clock = draw_clock()
full_img.paste(img_clock, (ICON_W+CODE_W, 0))
with open('o.png', 'wb') as f:
full_img.save(f, 'png')
return full_img
PIL绘图简介
-
创建图片('L'代表灰度模式,0xff表示默认颜色是白色)
img = Image.new('L', (width, height), 0xff)
-
图片拼贴(把
img_icon
粘贴到img
(30,0)的位置)
img.paste(img_icon, (30, 0))
-
创建画笔对象
draw = ImageDraw.Draw(img)
-
绘制矩形(左上角为(x1,y1),右下角为(x2,y2))
draw.rectangle((x1, y1, x2, y2), outline=0)
-
绘制直线(端点为(x1,y1)和(x2,y2),0x00表示线条颜色为黑色,宽度为1)
draw_name.line(((0, 0), (W2, 0)), fill=0x00, width=1)
-
创建字体对象('font/CascadiaMono.ttf'是字体路径,12是字号)
font = ImageFont.truetype('font/CascadiaMono.ttf'), 12)
-
获取文字的尺寸(获取用指定字体把'text'显示出来的尺寸,高为h,宽为w)
w, h = font.getsize('text')
-
绘制文本(把'text'用指定字体显示在(20,10)的位置,0x00表示字体颜色是黑色)
draw.text((20, 10), 'text', font=font, fill=0x00)
按键响应
原理
树莓派有2种引脚编号方式,BCM和BOARD,两者的区别只是对引脚的标记不同,使用GPIO前申明一下引脚的编号方式即可,使用命令gpio readall
可以查看所有引脚的编号、状态、电平状态
类似于51单片机的中断系统,树莓派也可以设定中断,不过先要把指定引脚设定为输入模式,设定完后,再创建一个“监听器”,当指定引脚的电平从高电平变为低电平的时候,就会自动调用指定函数,参数为引脚号。
我买的屏幕左边4个按键使用BCM编码的引脚编号为【5,6,13,19】,前两个用来控制翻页,第三个用来切换时钟下方显示的内容,第四个用来控制令牌的显示/隐藏。
实现
i> 这里只列出了事件响应的部分,具体功能的实现请参考obj.py
import RPi.GPIO as GPIO
from obj import Pi_2FA
# 使用的引脚序号
KEYS = [5, 6, 13, 19]
pi_2fa = Pi_2FA()
# 按键响应回调函数
def btn_callback(key):
if pi_2fa.Ready:
if key == KEYS[0]:
pi_2fa.page_down()
elif key == KEYS[1]:
pi_2fa.page_up()
elif key ==KEYS[2]:
pi_2fa.switch_mode()
elif key ==KEYS[3]:
pi_2fa.none_mode()
else:
print(key)
return
# 设置引脚编号方式为BCM
GPIO.setmode(GPIO.BCM)
for key in KEYS:
# 设定指定引脚为输入模式
GPIO.setup(key, GPIO.IN, pull_up_down=GPIO.PUD_UP)
# 为指定引脚添加事件响应函数
GPIO.add_event_detect(key, GPIO.RISING, callback=btn_callback, bouncetime=200)
本文链接:https://blog.chrxw.com/archives/2021/04/04/1557.html
转载请保留本文链接,谢谢