otp-cj OTP 库:HOTP/TOTP 实现(RFC 4226/6238)、Base32 编解码、otpauth URI 生成及 README
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# 构建产物
|
||||||
|
target/
|
||||||
|
# 临时测试产物
|
||||||
|
probe.cjo
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# otp-cj
|
||||||
|
|
||||||
|
仓颉(Cangjie)一次性密码(OTP)库,实现 **HOTP**(RFC 4226)与 **TOTP**(RFC 6238),提供 Base32 密钥编解码(RFC 4648)与 `otpauth://` URI 生成,可直接与 Google Authenticator / Microsoft Authenticator 等常见 OTP App 互通。
|
||||||
|
|
||||||
|
组织:`simcu` | 包名:`otp` | 版本:`1.0.0`
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- **TOTP**:基于时间步长的动态码,默认 6 位 / 30 秒步长,支持自定义位数、步长与时钟偏移窗口
|
||||||
|
- **HOTP**:基于计数器的一次性密码,默认 6 位
|
||||||
|
- **Base32**:RFC 4648 无填充变体编解码,解码时忽略大小写、空格、短横线与 `=`
|
||||||
|
- **otpauth:// URI**:生成可用于二维码的认证链接,并附带随机密钥生成工具
|
||||||
|
- **零三方依赖**:仅依赖仓颉标准库 `stdx.crypto.digest.SHA1`
|
||||||
|
- 密钥支持 **Base32 字符串**(Google Authenticator 惯例)与**原始字节**两种传入方式
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
在 `cjpm.toml` 中声明依赖(可改用中央仓版本号):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
"simcu::otp" = { path = "../../cangjie/otp-cj" }
|
||||||
|
# 或发布到中央仓后:"simcu::otp" = "1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
代码中引用:
|
||||||
|
|
||||||
|
```cangjie
|
||||||
|
import simcu::otp.core.*
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 生成密钥并绑定 OTP
|
||||||
|
|
||||||
|
```cangjie
|
||||||
|
// 生成随机 Base32 密钥(默认 20 字节 = 160 bit)
|
||||||
|
let secret = OtpUri.generateSecret() // 例如 "N6C5HNT2F633..."
|
||||||
|
|
||||||
|
// 生成 otpauth:// 链接,前端渲染成二维码供用户绑定
|
||||||
|
let uri = OtpUri.build(issuer: "GamePlatform", account: "18600127718", secretBase32: secret)
|
||||||
|
// otpauth://totp/GamePlatform%3A18600127718?secret=N6C5HNT2F633...&issuer=GamePlatform&algorithm=SHA1&digits=6&period=30
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 生成动态码
|
||||||
|
|
||||||
|
```cangjie
|
||||||
|
// TOTP:默认 6 位 / 30s 步长,基于当前时间
|
||||||
|
let code = Totp.generate(secret) // "351047"
|
||||||
|
|
||||||
|
// 自定义位数与步长
|
||||||
|
let code2 = Totp.generate(secret, digits: 8, period: 60)
|
||||||
|
|
||||||
|
// 原始字节密钥
|
||||||
|
let raw = Base32.decode(secret)
|
||||||
|
let code3 = Totp.generateBytes(raw)
|
||||||
|
|
||||||
|
// HOTP:基于计数器(第 N 次动态码)
|
||||||
|
let hotp = Hotp.generate(raw, counter: 0) // 默认 6 位
|
||||||
|
let hotp2 = Hotp.generate(raw, counter: 0, digits: 8)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 校验动态码
|
||||||
|
|
||||||
|
```cangjie
|
||||||
|
// TOTP 校验:默认允许前后各 1 个时间步的时钟偏移
|
||||||
|
let ok = Totp.verify(secret, code) // Bool
|
||||||
|
|
||||||
|
// 放宽/收紧偏移窗口
|
||||||
|
let ok2 = Totp.verify(secret, code, digits: 6, period: 30, window: 2)
|
||||||
|
|
||||||
|
// 字节密钥版本
|
||||||
|
let ok3 = Totp.verifyBytes(raw, code)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Base32 编解码
|
||||||
|
|
||||||
|
```cangjie
|
||||||
|
let raw = Base32.decode("N6C5HNT2F633") // 忽略空格/短横线/大小写
|
||||||
|
let encoded = Base32.encode(raw) // 往返后还原 "N6C5HNT2F633"
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
包结构:`simcu::otp.core`
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `Base32` | RFC 4648 无填充 Base32 编解码;`encode(data)` / `decode(input)`,非法字符抛 `IllegalArgumentException` |
|
||||||
|
| `HmacSha1` | RFC 2104 / 4226 HMAC-SHA1(blockSize=64);`init(key)` / `compute(data)` |
|
||||||
|
| `Hotp` | RFC 4226;`generate(secret, counter[, digits])` |
|
||||||
|
| `Totp` | RFC 6238;`generate/generateAt/generateBytes(..., digits, period)` 与 `verify/verifyBytes(..., digits, period, window)` 多种重载;默认 `period=30`、`window=1` |
|
||||||
|
| `OtpUri` | `generateSecret([bytes])` 随机密钥;`build(issuer, account, secret[, digits, period])` 生成 otpauth:// 链接 |
|
||||||
|
|
||||||
|
常用常量:
|
||||||
|
|
||||||
|
- `Totp.DEFAULT_PERIOD = 30`
|
||||||
|
- `Totp.DEFAULT_T0 = 0`
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- **HMAC-SHA1 为何自实现**:仓颉标准库 `std.crypto.digest` 的 HMAC 目前仅支持 SHA512,而 HOTP/TOTP 标准基于 HMAC-SHA1,因此本库基于 `stdx.crypto.digest.SHA1` 实现了 RFC 2104 的 HMAC-SHA1。
|
||||||
|
- **兼容性**:`Totp.generate` / `Totp.verify` 的结果与 Google Authenticator 完全一致,可交叉验证。
|
||||||
|
- **服务端校验建议**:绑定阶段将 Base32 secret 持久化到账号表(`otp_secret` 字段);校验时用 `Totp.verify(secret, code)`。
|
||||||
|
|
||||||
|
## 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cjpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
测试覆盖:Base32 编解码往返、HOTP/TOTP 与 RFC 官方测试向量的对拍、时钟偏移窗口、otpauth:// URI 生成等。
|
||||||
|
|
||||||
|
## 配合 SimApi 使用
|
||||||
|
|
||||||
|
在 simapi 后端中作为账号二次验证通道:
|
||||||
|
|
||||||
|
```cangjie
|
||||||
|
// AuthCodeService 示例:绑定 OTP 的账号优先接受 TOTP 动态码
|
||||||
|
let otpOk = Totp.verify(acc.otpSecret, code)
|
||||||
|
```
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
cjc-version = "1.1.3"
|
||||||
|
name = "otp"
|
||||||
|
organization = "simcu"
|
||||||
|
description = "SimApi OTP(一次性密码)库:HOTP/TOTP 实现(RFC 4226/6238),Base32 密钥与 otpauth:// URI 生成"
|
||||||
|
version = "1.0.0"
|
||||||
|
target-dir = ""
|
||||||
|
output-type = "static"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
|
||||||
|
[target]
|
||||||
|
[target.x86_64-w64-mingw32]
|
||||||
|
compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated"
|
||||||
|
[target.x86_64-w64-mingw32.bin-dependencies]
|
||||||
|
path-option = [ "${CANGJIE_STDX_PATH}" ]
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated"
|
||||||
|
[target.x86_64-unknown-linux-gnu.bin-dependencies]
|
||||||
|
path-option = [ "${CANGJIE_STDX_PATH}" ]
|
||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated"
|
||||||
|
[target.aarch64-unknown-linux-gnu.bin-dependencies]
|
||||||
|
path-option = [ "${CANGJIE_STDX_PATH}" ]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package simcu::otp
|
||||||
|
|
||||||
|
public import simcu::otp.core.*
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package simcu::otp.core
|
||||||
|
|
||||||
|
import std.collection.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base32 编解码(RFC 4648 无填充变体)。
|
||||||
|
* OTP 的共享密钥(secret)惯例使用 Base32 表示(Google Authenticator / otpauth URI 标准)。
|
||||||
|
*/
|
||||||
|
public class Base32 {
|
||||||
|
private static let ALPHABET: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||||
|
|
||||||
|
/// 字节数组 → Base32 字符串(无 padding)
|
||||||
|
public static func encode(data: Array<Byte>): String {
|
||||||
|
var sb = StringBuilder()
|
||||||
|
var buffer: Int64 = 0
|
||||||
|
var bits = 0
|
||||||
|
for (b in data) {
|
||||||
|
buffer = (buffer << 8) | Int64(b)
|
||||||
|
bits += 8
|
||||||
|
while (bits >= 5) {
|
||||||
|
bits -= 5
|
||||||
|
let idx = (buffer >> bits) & 0x1f
|
||||||
|
sb.append(Rune(ALPHABET[idx]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) {
|
||||||
|
let idx = (buffer << (5 - bits)) & 0x1f
|
||||||
|
sb.append(Rune(ALPHABET[idx]))
|
||||||
|
}
|
||||||
|
sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base32 字符串 → 字节数组(忽略空格/短横线/大小写;含非法字符抛 IllegalArgumentException)
|
||||||
|
public static func decode(input: String): Array<Byte> {
|
||||||
|
let cleaned = toUpper(input).replace(" ", "").replace("-", "").replace("=", "")
|
||||||
|
let result = ArrayList<Byte>()
|
||||||
|
var buffer: Int64 = 0
|
||||||
|
var bits = 0
|
||||||
|
for (ch in cleaned) {
|
||||||
|
let idx = indexInAlphabet(ch)
|
||||||
|
buffer = (buffer << 5) | idx
|
||||||
|
bits += 5
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8
|
||||||
|
result.add(UInt8((buffer >> bits) & 0xff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查找字符(UInt8)在字母表中的位置,非法字符抛 IllegalArgumentException
|
||||||
|
private static func indexInAlphabet(ch: UInt8): Int64 {
|
||||||
|
var i: Int64 = 0
|
||||||
|
for (c in ALPHABET) {
|
||||||
|
if (c == ch) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("invalid base32 character: ${ch}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手工转大写(仓颉 String 无 toUpperCase 成员;遍历得到 UInt8)
|
||||||
|
private static func toUpper(s: String): String {
|
||||||
|
var sb = StringBuilder()
|
||||||
|
for (ch in s) {
|
||||||
|
if (ch >= UInt8(97) && ch <= UInt8(122)) {
|
||||||
|
sb.append(Rune(Int64(ch) - 32))
|
||||||
|
} else {
|
||||||
|
sb.append(Rune(ch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package simcu::otp.core
|
||||||
|
|
||||||
|
import stdx.crypto.digest.SHA1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HMAC-SHA1 实现(RFC 2104 / RFC 4226 第 5 节)。
|
||||||
|
* 仓颉标准库 std.crypto.digest 的 HMAC 目前仅支持 SHA512(HashType.SHA512),
|
||||||
|
* 而 HOTP/TOTP 标准基于 HMAC-SHA1,故在此基于 stdx 的 SHA1 自实现(blockSize=64)。
|
||||||
|
*/
|
||||||
|
public class HmacSha1 {
|
||||||
|
private let ipad: Array<Byte>
|
||||||
|
private let opad: Array<Byte>
|
||||||
|
|
||||||
|
public init(key: Array<Byte>) {
|
||||||
|
let blockSize: Int64 = 64
|
||||||
|
var tempKey = key
|
||||||
|
if (key.size > blockSize) {
|
||||||
|
let sha = SHA1()
|
||||||
|
sha.write(key)
|
||||||
|
tempKey = sha.finish()
|
||||||
|
}
|
||||||
|
ipad = Array<Byte>(blockSize, repeat: 0)
|
||||||
|
opad = Array<Byte>(blockSize, repeat: 0)
|
||||||
|
tempKey.copyTo(ipad, 0, 0, tempKey.size)
|
||||||
|
tempKey.copyTo(opad, 0, 0, tempKey.size)
|
||||||
|
for (i in 0..blockSize) {
|
||||||
|
ipad[i] ^= 0x36
|
||||||
|
opad[i] ^= 0x5c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算 HMAC-SHA1(key, data)
|
||||||
|
public func compute(data: Array<Byte>): Array<Byte> {
|
||||||
|
let inner = SHA1()
|
||||||
|
inner.write(ipad)
|
||||||
|
inner.write(data)
|
||||||
|
let innerHash = inner.finish()
|
||||||
|
let outer = SHA1()
|
||||||
|
outer.write(opad)
|
||||||
|
outer.write(innerHash)
|
||||||
|
outer.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package simcu::otp.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HOTP(HMAC-based One-Time Password, RFC 4226)。
|
||||||
|
* 公式:HOTP(K,C) = Truncate(HMAC-SHA1(K, C)) mod 10^digits
|
||||||
|
*/
|
||||||
|
public class Hotp {
|
||||||
|
/// 生成一次性密码(默认 6 位)。counter 为 8 字节大端计数。
|
||||||
|
public static func generate(secret: Array<Byte>, counter: Int64): String {
|
||||||
|
generate(secret, counter, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成一次性密码,指定位数。counter 为 8 字节大端计数。
|
||||||
|
public static func generate(secret: Array<Byte>, counter: Int64, digits: Int64): String {
|
||||||
|
// counter → 8 字节大端
|
||||||
|
var counterBytes = Array<Byte>(8, repeat: 0)
|
||||||
|
var c = counter
|
||||||
|
var i = 7
|
||||||
|
while (i >= 0) {
|
||||||
|
counterBytes[i] = UInt8(c & 0xff)
|
||||||
|
c = c >> 8
|
||||||
|
i -= 1
|
||||||
|
}
|
||||||
|
let hmac = HmacSha1(secret)
|
||||||
|
let hs = hmac.compute(counterBytes)
|
||||||
|
let offset = Int64(hs[19]) & 0x0f
|
||||||
|
let binCode = ((Int64(hs[offset]) & 0x7f) << 24) |
|
||||||
|
(Int64(hs[offset + 1]) << 16) |
|
||||||
|
(Int64(hs[offset + 2]) << 8) |
|
||||||
|
Int64(hs[offset + 3])
|
||||||
|
let mod = pow10(digits)
|
||||||
|
let code = binCode % mod
|
||||||
|
padLeft(code.toString(), digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func pow10(n: Int64): Int64 {
|
||||||
|
var r: Int64 = 1
|
||||||
|
var i = 0
|
||||||
|
while (i < n) {
|
||||||
|
r *= 10
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func padLeft(s: String, width: Int64): String {
|
||||||
|
var r = s
|
||||||
|
while (r.size < width) {
|
||||||
|
r = "0" + r
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package simcu::otp.core
|
||||||
|
|
||||||
|
import std.random.*
|
||||||
|
import std.convert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* otpauth:// URI 生成(Google Authenticator / 常见 OTP App 标准)。
|
||||||
|
* 格式:otpauth://totp/{issuer}:{account}?secret={base32}&issuer={issuer}&algorithm=SHA1&digits=6&period=30
|
||||||
|
* 同时提供随机 Base32 secret 生成工具。
|
||||||
|
*/
|
||||||
|
public class OtpUri {
|
||||||
|
/// 生成随机 Base32 secret(默认 20 字节 = 160 bit,与 RFC 4226 密钥长度一致)
|
||||||
|
public static func generateSecret(): String {
|
||||||
|
generateSecret(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成指定字节数的随机 Base32 secret
|
||||||
|
public static func generateSecret(bytes: Int64): String {
|
||||||
|
let rnd = Random()
|
||||||
|
let data = Array<Byte>(bytes, repeat: 0)
|
||||||
|
for (i in 0..bytes) {
|
||||||
|
data[i] = UInt8(rnd.nextUInt64() & 0xff)
|
||||||
|
}
|
||||||
|
Base32.encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 otpauth:// 链接(默认 6 位 / 30s 步长)
|
||||||
|
public static func build(issuer: String, account: String, secretBase32: String): String {
|
||||||
|
build(issuer, account, secretBase32, 6, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 otpauth:// 链接(指定位数)
|
||||||
|
public static func build(issuer: String, account: String, secretBase32: String, digits: Int64): String {
|
||||||
|
build(issuer, account, secretBase32, digits, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 otpauth:// 链接(指定位数与步长)
|
||||||
|
public static func build(issuer: String, account: String, secretBase32: String, digits: Int64, period: Int64): String {
|
||||||
|
let label = issuer + ":" + account
|
||||||
|
var sb = StringBuilder()
|
||||||
|
sb.append("otpauth://totp/")
|
||||||
|
sb.append(urlEncode(label))
|
||||||
|
sb.append("?secret=")
|
||||||
|
sb.append(secretBase32)
|
||||||
|
sb.append("&issuer=")
|
||||||
|
sb.append(urlEncode(issuer))
|
||||||
|
sb.append("&algorithm=SHA1&digits=")
|
||||||
|
sb.append(digits.toString())
|
||||||
|
sb.append("&period=")
|
||||||
|
sb.append(period.toString())
|
||||||
|
sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func urlEncode(s: String): String {
|
||||||
|
var sb = StringBuilder()
|
||||||
|
for (ch in s) {
|
||||||
|
match (ch) {
|
||||||
|
case ':' => sb.append("%3A")
|
||||||
|
case '/' => sb.append("%2F")
|
||||||
|
case ' ' => sb.append("%20")
|
||||||
|
case _ => sb.append(Rune(ch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package simcu::otp.core
|
||||||
|
|
||||||
|
import std.time.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP(Time-based One-Time Password, RFC 6238)。
|
||||||
|
* 公式:TOTP(K) = HOTP(K, T),T = floor((UnixTime - T0) / X),X 默认 30 秒。
|
||||||
|
* 支持密钥以 Base32 字符串(Google Authenticator 标准)或原始字节传入。
|
||||||
|
*/
|
||||||
|
public class Totp {
|
||||||
|
public static let DEFAULT_PERIOD: Int64 = 30
|
||||||
|
public static let DEFAULT_T0: Int64 = 0
|
||||||
|
|
||||||
|
/// 生成 TOTP(secret 为 Base32 字符串,默认 6 位 / 30s 步长)
|
||||||
|
public static func generate(secretBase32: String): String {
|
||||||
|
generateAt(secretBase32, currentUnixSeconds(), 6, DEFAULT_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 TOTP(secret 为 Base32 字符串,指定位数)
|
||||||
|
public static func generate(secretBase32: String, digits: Int64): String {
|
||||||
|
generateAt(secretBase32, currentUnixSeconds(), digits, DEFAULT_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 TOTP(secret 为 Base32 字符串,指定位数与步长)
|
||||||
|
public static func generate(secretBase32: String, digits: Int64, period: Int64): String {
|
||||||
|
generateAt(secretBase32, currentUnixSeconds(), digits, period)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在指定 Unix 秒生成 TOTP(默认 6 位 / 30s 步长)
|
||||||
|
public static func generateAt(secretBase32: String, atUnix: Int64): String {
|
||||||
|
generateAt(secretBase32, atUnix, 6, DEFAULT_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在指定 Unix 秒生成 TOTP(指定位数)
|
||||||
|
public static func generateAt(secretBase32: String, atUnix: Int64, digits: Int64): String {
|
||||||
|
generateAt(secretBase32, atUnix, digits, DEFAULT_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在指定 Unix 秒生成 TOTP(指定位数与步长)
|
||||||
|
public static func generateAt(secretBase32: String, atUnix: Int64, digits: Int64, period: Int64): String {
|
||||||
|
let counter = (atUnix - DEFAULT_T0) / period
|
||||||
|
Hotp.generate(Base32.decode(secretBase32), counter, digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 TOTP(secret 为原始字节,默认 6 位 / 30s 步长)
|
||||||
|
public static func generateBytes(secret: Array<Byte>): String {
|
||||||
|
generateBytesAt(secret, currentUnixSeconds(), 6, DEFAULT_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 TOTP(secret 为原始字节,指定位数)
|
||||||
|
public static func generateBytes(secret: Array<Byte>, digits: Int64): String {
|
||||||
|
generateBytesAt(secret, currentUnixSeconds(), digits, DEFAULT_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在指定 Unix 秒生成 TOTP(原始字节密钥,默认 6 位 / 30s 步长)
|
||||||
|
public static func generateBytesAt(secret: Array<Byte>, atUnix: Int64): String {
|
||||||
|
generateBytesAt(secret, atUnix, 6, DEFAULT_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在指定 Unix 秒生成 TOTP(原始字节密钥,指定位数)
|
||||||
|
public static func generateBytesAt(secret: Array<Byte>, atUnix: Int64, digits: Int64): String {
|
||||||
|
generateBytesAt(secret, atUnix, digits, DEFAULT_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在指定 Unix 秒生成 TOTP(原始字节密钥,指定位数与步长)
|
||||||
|
public static func generateBytesAt(secret: Array<Byte>, atUnix: Int64, digits: Int64, period: Int64): String {
|
||||||
|
let counter = (atUnix - DEFAULT_T0) / period
|
||||||
|
Hotp.generate(secret, counter, digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验:允许前后 window 个时间步的时钟偏移(默认允许当前步 ±1)
|
||||||
|
public static func verify(secretBase32: String, code: String): Bool {
|
||||||
|
verify(secretBase32, code, 6, DEFAULT_PERIOD, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验(指定位数)
|
||||||
|
public static func verify(secretBase32: String, code: String, digits: Int64): Bool {
|
||||||
|
verify(secretBase32, code, digits, DEFAULT_PERIOD, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验(指定位数与步长)
|
||||||
|
public static func verify(secretBase32: String, code: String, digits: Int64, period: Int64): Bool {
|
||||||
|
verify(secretBase32, code, digits, period, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验(指定位数、步长与窗口)
|
||||||
|
public static func verify(secretBase32: String, code: String, digits: Int64, period: Int64, window: Int64): Bool {
|
||||||
|
let nowSeconds = currentUnixSeconds()
|
||||||
|
for (i in -window..window + 1) {
|
||||||
|
let expected = generateAt(secretBase32, nowSeconds + i * period, digits, period)
|
||||||
|
if (expected == code) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验(原始字节密钥,默认窗口 ±1)
|
||||||
|
public static func verifyBytes(secret: Array<Byte>, code: String): Bool {
|
||||||
|
verifyBytes(secret, code, 6, DEFAULT_PERIOD, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验(原始字节密钥,指定位数)
|
||||||
|
public static func verifyBytes(secret: Array<Byte>, code: String, digits: Int64): Bool {
|
||||||
|
verifyBytes(secret, code, digits, DEFAULT_PERIOD, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验(原始字节密钥,指定位数与步长)
|
||||||
|
public static func verifyBytes(secret: Array<Byte>, code: String, digits: Int64, period: Int64): Bool {
|
||||||
|
verifyBytes(secret, code, digits, period, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验(原始字节密钥,指定位数、步长与窗口)
|
||||||
|
public static func verifyBytes(secret: Array<Byte>, code: String, digits: Int64, period: Int64, window: Int64): Bool {
|
||||||
|
let nowSeconds = currentUnixSeconds()
|
||||||
|
for (i in -window..window + 1) {
|
||||||
|
let expected = generateBytesAt(secret, nowSeconds + i * period, digits, period)
|
||||||
|
if (expected == code) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func currentUnixSeconds(): Int64 {
|
||||||
|
DateTime.now().toUnixTimeStamp().toSeconds()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* otp 库单元测试(cjpm test)。
|
||||||
|
* 覆盖:Base32 编解码(RFC 4648)/ HMAC-SHA1(RFC 2202)/
|
||||||
|
* HOTP(RFC 4226 附录 D)/ TOTP(RFC 6238 附录 B)/
|
||||||
|
* otpauth URI 生成 / 随机 secret。
|
||||||
|
*/
|
||||||
|
|
||||||
|
package simcu::otp.tests
|
||||||
|
|
||||||
|
import std.unittest.*
|
||||||
|
import std.unittest.testmacro.*
|
||||||
|
import std.convert.*
|
||||||
|
import simcu::otp.*
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public class OtpTests {
|
||||||
|
private static func ascii(s: String): Array<Byte> {
|
||||||
|
let bytes = Array<Byte>(s.size, repeat: 0)
|
||||||
|
var i = 0
|
||||||
|
for (ch in s) {
|
||||||
|
bytes[i] = UInt8(Int64(ch))
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base32:RFC 4648 无填充测试向量
|
||||||
|
@TestCase
|
||||||
|
public func base32Vectors(): Unit {
|
||||||
|
@Expect(Base32.encode(ascii("")), "")
|
||||||
|
@Expect(Base32.encode(ascii("f")), "MY")
|
||||||
|
@Expect(Base32.encode(ascii("fo")), "MZXQ")
|
||||||
|
@Expect(Base32.encode(ascii("foo")), "MZXW6")
|
||||||
|
@Expect(Base32.encode(ascii("foob")), "MZXW6YQ")
|
||||||
|
@Expect(Base32.encode(ascii("fooba")), "MZXW6YTB")
|
||||||
|
@Expect(Base32.encode(ascii("foobar")), "MZXW6YTBOI")
|
||||||
|
// 解码回原串
|
||||||
|
@Expect(String.fromUtf8(Base32.decode("MZXW6YTBOI")), "foobar")
|
||||||
|
@Expect(String.fromUtf8(Base32.decode("mzxw6ytboi")), "foobar")
|
||||||
|
@Expect(String.fromUtf8(Base32.decode("MZXW6YTBOI======")), "foobar")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HMAC-SHA1:RFC 2202 测试用例 1
|
||||||
|
@TestCase
|
||||||
|
public func hmacSha1Vectors(): Unit {
|
||||||
|
let key = Array<Byte>(20, repeat: 0x0b)
|
||||||
|
let data = ascii("Hi There")
|
||||||
|
let expected = "b617318655057264e28bc0b6fb378c8ef146be00"
|
||||||
|
let hmac = HmacSha1(key)
|
||||||
|
let actual = toHexString(hmac.compute(data))
|
||||||
|
@Expect(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HOTP:RFC 4226 附录 D 官方向量(6 位)
|
||||||
|
@TestCase
|
||||||
|
public func hotpVectors(): Unit {
|
||||||
|
let key = ascii("12345678901234567890")
|
||||||
|
let expected = ["755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", "399871", "520489"]
|
||||||
|
for (i in 0..10) {
|
||||||
|
@Expect(Hotp.generate(key, i), expected[i])
|
||||||
|
}
|
||||||
|
// 8 位(counter=0 正确值为 84755224,由 RFC 4226 动态截断独立验证)
|
||||||
|
@Expect(Hotp.generate(key, 0, 8), "84755224")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TOTP:RFC 6238 附录 B 官方向量(8 位,30s 步长)
|
||||||
|
@TestCase
|
||||||
|
public func totpVectors(): Unit {
|
||||||
|
let secretBase32 = Base32.encode(ascii("12345678901234567890"))
|
||||||
|
let cases: Array<(Int64, String)> = [
|
||||||
|
(59, "94287082"),
|
||||||
|
(1111111109, "07081804"),
|
||||||
|
(1111111111, "14050471"),
|
||||||
|
(1234567890, "89005924"),
|
||||||
|
(2000000000, "69279037"),
|
||||||
|
(20000000000, "65353130"),
|
||||||
|
]
|
||||||
|
for (c in cases) {
|
||||||
|
@Expect(Totp.generateAt(secretBase32, c[0], 8), c[1])
|
||||||
|
}
|
||||||
|
// 原始字节密钥路径
|
||||||
|
@Expect(Totp.generateBytesAt(ascii("12345678901234567890"), 59, 8), "94287082")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TOTP 校验:正确码通过,错误码拒绝
|
||||||
|
@TestCase
|
||||||
|
public func totpVerify(): Unit {
|
||||||
|
let secret = "JBSWY3DPEHPK3PXP" // Base32("Hello!\xDE\xAD\xBE\xEF")
|
||||||
|
let code = Totp.generate(secret)
|
||||||
|
@Expect(Totp.verify(secret, code), true)
|
||||||
|
@Expect(Totp.verify(secret, "000000"), false)
|
||||||
|
// 窗口偏移 0 → 严格当前步
|
||||||
|
@Expect(Totp.verify(secret, code, 6, 30, 0), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// otpauth URI 生成
|
||||||
|
@TestCase
|
||||||
|
public func otpUri(): Unit {
|
||||||
|
let secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
let uri = OtpUri.build("MyGame", "player1", secret)
|
||||||
|
@Expect(uri.startsWith("otpauth://totp/"), true)
|
||||||
|
@Expect(uri.contains("secret=JBSWY3DPEHPK3PXP"), true)
|
||||||
|
@Expect(uri.contains("issuer=MyGame"), true)
|
||||||
|
@Expect(uri.contains("digits=6"), true)
|
||||||
|
@Expect(uri.contains("period=30"), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 随机 secret:Base32 合法且可解码
|
||||||
|
@TestCase
|
||||||
|
public func randomSecret(): Unit {
|
||||||
|
let s = OtpUri.generateSecret(20)
|
||||||
|
@Expect(s.size, 32)
|
||||||
|
let decoded = Base32.decode(s)
|
||||||
|
@Expect(decoded.size, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func toHexString(data: Array<Byte>): String {
|
||||||
|
var sb = StringBuilder()
|
||||||
|
for (b in data) {
|
||||||
|
sb.append(byteToHex(b))
|
||||||
|
}
|
||||||
|
sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func byteToHex(b: Byte): String {
|
||||||
|
let hex = "0123456789abcdef"
|
||||||
|
var s = StringBuilder()
|
||||||
|
s.append(Rune(hex[Int64(b) >> 4]))
|
||||||
|
s.append(Rune(hex[Int64(b) & 0x0f]))
|
||||||
|
s.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user