前言

总共花了4天休息时间做的小玩意,支持显示通用TOTP令牌(6位数字)和STEAM令牌(5位),同时也是个桌面时钟。

源码获取:GitHub

令牌原理

简单的介绍一下两个令牌的原理,不感兴趣的可以参考下一节

原理

令牌类似于短信验证码,是一种一次性密码OTP,核心原理很简单,服务器和用户事先约定好一个Secret,然后用户通过特殊的算法(一般用Secret + 时间戳或者计数器作为参数),得到一串字符,服务器也通过同样的算法得到一串字符,如果两者相同,那么就证明了我是我

常见的令牌大多是基于时间的令牌TOTP,也就是用当前的时间戳作为输入,只要保证和服务端时间差不超过令牌有效期(常见的实现是30秒)就能确保通过验证。优点是令牌客户端可以不需要联网,缺点是可能因为时间差导致验证失败。

另一种实现是基于HMAC的令牌HOTP,这种令牌需要和服务器约定一个计数器,客户端刷新一次令牌,就告诉服务器,让两边的计数器同步,这样用户和服务器得到的结果才能一样,才能通过验证。缺点显而易见,就是令牌客户端需要联网。

STEAM的令牌系统类似于TOTP,也是基于时间戳的,只是算法略有区别,最明显的区别是普通TOTP计算得到的是6位数字,STEAM令牌是5位数字或字母。

Secret一定要保管好,千万不要向他人透露。

完整代码位于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】

两个算法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

屏幕显示

我使用的是微雪的2.7寸墨水屏,也可以使用别的尺寸的屏幕,自行修改图片生成函数进行适配即可。
买屏幕的时候直接买适配树莓派的就行了,基本上都提供Python的驱动,直接用就行,非常方便。

常见的屏幕一般是SPI接口,树莓派默认是不开启SPI接口的,可以使用官方的工具rasbpi-config开启,或者手动编辑/boot/config.txt,在最后一行加一个dtparam=spi=on,重启后生效。

img.py

如果对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】,前两个用来控制翻页,第三个用来切换时钟下方显示的内容,第四个用来控制令牌的显示/隐藏。

实现

这里只列出了事件响应的部分,具体功能的实现请参考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)
最后修改:2021 年 04 月 04 日
Null