otp-cj OTP 库:HOTP/TOTP 实现(RFC 4226/6238)、Base32 编解码、otpauth URI 生成及 README

This commit is contained in:
2026-08-21 08:43:24 +08:00
commit 014bdec72a
11 changed files with 652 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# 构建产物
target/
# 临时测试产物
probe.cjo
+121
View File
@@ -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)
```
+3
View File
@@ -0,0 +1,3 @@
version = 0
[requires]
+24
View File
@@ -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}" ]
+3
View File
@@ -0,0 +1,3 @@
package simcu::otp
public import simcu::otp.core.*
+75
View File
@@ -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()
}
}
+43
View File
@@ -0,0 +1,43 @@
package simcu::otp.core
import stdx.crypto.digest.SHA1
/**
* HMAC-SHA1 实现(RFC 2104 / RFC 4226 第 5 节)。
* 仓颉标准库 std.crypto.digest 的 HMAC 目前仅支持 SHA512HashType.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()
}
}
+53
View File
@@ -0,0 +1,53 @@
package simcu::otp.core
/**
* HOTPHMAC-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
}
}
+66
View File
@@ -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()
}
}
+128
View File
@@ -0,0 +1,128 @@
package simcu::otp.core
import std.time.*
/**
* TOTPTime-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
/// 生成 TOTPsecret 为 Base32 字符串,默认 6 位 / 30s 步长)
public static func generate(secretBase32: String): String {
generateAt(secretBase32, currentUnixSeconds(), 6, DEFAULT_PERIOD)
}
/// 生成 TOTPsecret 为 Base32 字符串,指定位数)
public static func generate(secretBase32: String, digits: Int64): String {
generateAt(secretBase32, currentUnixSeconds(), digits, DEFAULT_PERIOD)
}
/// 生成 TOTPsecret 为 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)
}
/// 生成 TOTPsecret 为原始字节,默认 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()
}
}
+132
View File
@@ -0,0 +1,132 @@
/*
* otp 库单元测试(cjpm test)。
* 覆盖:Base32 编解码(RFC 4648/ HMAC-SHA1RFC 2202/
* HOTPRFC 4226 附录 D/ TOTPRFC 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
}
/// Base32RFC 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-SHA1RFC 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)
}
/// HOTPRFC 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")
}
/// TOTPRFC 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)
}
/// 随机 secretBase32 合法且可解码
@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()
}
}